Security - andreaderuvo/argus GitHub Wiki
Argus is remote shell access. Anyone holding the token can run anything the user running Argus can run. Treat it exactly like an SSH private key.
-
One token, 64 hex characters from
secrets.token_hex, compared in constant time. Every route and every WebSocket is behind it. - Safe defaults: loopback only, read-only, no proxying, no extra mounts. Everything that can change the machine is opt-in.
-
A path jail that canonicalizes before comparing, so neither
../../etc/passwdnor a symlink pointing out of a root gets through. A path outside the roots is refused identically whether or not it exists, so the API cannot be used to map the filesystem. -
Rendered documents are sandboxed into an opaque origin, so somebody else's HTML
report cannot read the token out of localStorage. Markdown is escaped before rendering
and
javascript:links are stripped. -
Nothing is proxied unless proxying is on and that port was opened by hand, and what
is forwarded carries neither the token in the query nor the
Authorizationheader โ the service behind the proxy, and its log, never see the credential. - The tmux config is validated on a throwaway server before being applied to the one holding your sessions.
- Plain HTTP by default. Over HTTP the token crosses the network in the clear, and so does everything you type. Either give Argus a certificate (below, HTTPS) or put it behind Tailscale, a tunnel or a reverse proxy on anything but a trusted LAN.
-
The token is in the link you open once โ as
#token=, a fragment, which a browser never sends to a server or a proxy, so it is in your browser's history but not in a log. What does still put?token=in a reverse proxy's access log: the terminal's WebSocket, downloads and images, and/proxy/<port>/. Rotate the token by editing~/.config/argus/config.yamland restarting. -
No second factor, no accounts, and no throttle on guessing. A wrong token is refused as
fast as you can send it. What there is: one key per device, weaker keys for boards and agents,
a cap on how many agents start in a minute (
launches_a_minute, 12 by default), and the journal below. - Anyone with the token can read every file under the configured roots, and with
--allow-write, change them.
Journal, in the sidebar, answers one question: has somebody been in here.
In it: everything that changed something, and everything that was refused, whatever method it used. Not in it: successful reads. Listing a folder every four seconds while you scroll is most of the traffic and none of the interesting part, and a file full of it is a file nobody opens. A break-in does not look like a suspicious write โ it looks like a run of 401s from an address you do not recognise, and a scanner sends GETs.
Each line carries the action, which key did it (the config token, a device by name, or a board), the address, the status and the time. Behind a reverse proxy the peer is always loopback and the real client is in a header, so both are kept and shown apart: a header is whatever the sender wrote.
Refusals from one address are collapsed โ a scanner can produce thousands a minute โ but the count is kept, so twelve knocks read as twelve.
Only the token from the config can read it: a record a stolen device can read is a record that tells whoever took it what you can see.
It is not tamper-proof and does not pretend to be. Anything with the master token can delete the file, and a shell on the machine certainly can. It answers "what happened" for a person looking back, which is the question that comes up; it is not evidence.
Panoptes keeps its own, for the three things that happen only on a board.
One token means losing a phone costs you every device, because the only remedy is rotating the secret they all share. Settings โ Devices gives each one its own.
The token is shown once, in a link and a QR code; only a sha256 of it is kept in
~/.config/argus/devices.json (mode 600), so losing that file does not hand anyone a working
key. There is no bcrypt or argon2 on it deliberately: slow hashes exist to make guessable
secrets expensive to attack, and these are 256 bits of randomness.
Two properties worth knowing:
- Revoking one leaves the others. It takes effect on that device's next request โ the file is re-read every time a token is presented, so there is nothing to restart.
- A device may work but may not manage devices. Only the token in the config can add or revoke, and the middleware enforces that rather than the interface hiding it. A phone that is lost cannot mint itself a second key, and cannot lock you out of your own machine.
The list says when each device was last used, and the journal records which of them did what, by name.
Argus serves HTTPS itself when the config names a certificate and its key:
tls_cert: /etc/ssl/argus/fullchain.pem
tls_key: /etc/ssl/argus/privkey.pemBoth or neither (one alone refuses to start). The links it prints (--print-url, --qr, the
banner) then say https://. The plugin's hooks, argus-say and argus-mcp read the same two keys
and talk to it over https on loopback, without checking the certificate there โ it names the public
host, not 127.0.0.1, and nothing leaves the machine. A certificate is also what makes the page a
secure context: the service worker (install as an app, the Reload bar), desktop notifications and
the in-app QR scanner only exist there. A certificate from mkcert, Let's Encrypt or your company's
CA all work; tailscale serve is the other way to the same result without touching the config.
The page keeps its token in the browser's storage, and a phone loses that more often than one
would think: Safari deletes what a page's script stored after seven days without a visit, and a
link opened from a chat app lands in that app's own browser, which has its own storage. So once
a token has worked, the server also sets a cookie holding it, the way Jupyter does after its
?token= link:
- HttpOnly: no script can read it, the page's own included.
-
Read at one path only,
/api/remember, which hands the token back to this same address so the page can carry on. Every other request still has to carry the token itself; the cookie opens nothing. - SameSite=Strict, 400 days, renewed on every visit. Over https, Secure too.
- Only a key worth remembering: the full token or a device's. A watcher's or an agent's key is never kept, a remembered key that stops working (rotated, device revoked) is dropped, and signing out forgets it.
One thing it cannot do: a browser treats each address as a separate site. If you reach Argus as
http://10.0.0.5:8080 one day and as http://box.lan:8080 the next, each is signed in on its
own. Use one address, or bookmark it. The port is yours: 8080 by default, 8090 in Docker.
python3 -c "import secrets; print(secrets.token_hex(32))"
# paste it into ~/.config/argus/config.yaml
systemctl --user restart argusEvery browser that was paired with the config token then has to be paired again. Devices with a token of their own keep working: revoke those one by one under Settings โ Devices.
An agent in one of your sessions can already read the config: it runs as you. So an agent key is not about granting access. It is about the shape of the access it takes.
agents:
- name: claude
token: <a different one>An agent key opens a fixed list of routes (AGENT_ROUTES in app/auth.py, held by a test) and
nothing else. It may see who is here and what they are doing, read your preferences and prompts,
ring the bell and hear the bells, ask you a question and wait for the answer, pass a sentence to
another session, start something from the launcher list, make a git worktree, post a run, make or
rename a desk, read and move along the to-do list, read the teams, take part in one (its task, its
report) and check or propose one โ and request a fixed list of other actions, which you
approve with a tap. The full list is in An agent driving Argus, the
whole table in What an agent can do.
What it cannot do:
- Answer a question. It may wait for your answer, never give one for you.
- Open a WebSocket. The terminal is one, and a terminal could type into any session.
-
Start an agent that asks nobody, by itself. A choice marked as dangerous, such as
--dangerously-skip-permissions, is refused with 403. It may request it; you tap Do it or No. -
Kill a session, start, pause or stop a team, remove a worktree, delete a to-do โ by itself.
The same: only as a request you approve. Settings โ Agents (or
agents_without_askingin the config) lets the ones you tick through without the tap โ any of them, the risky ones marked; the boxes are in the preferences, which an agent's key cannot write. - Expose or administer anything. No file writes through Argus, no proxied port, no device tokens, no labels, no journal, no tmux config, no stopping the server.
-
Be remembered.
/api/remembernever keeps an agent's key.
Everything else answers 403. What an agent's key does shows up in the journal under its name
(claude (agent)); what Argus did on a request you approved shows as the config token.
The token in your browser opens everything: a shell, every file under the roots, writes if they are on. That is the right shape for the person using the machine and the wrong shape for a board watching six of them, which would then be a box containing six full keys.
So a machine can hand out weaker ones:
token: <the real one, the one you use>
watchers:
- name: panoptes
token: <a different one, at least 16 characters>A watcher token opens exactly one door, GET /api/overview, and that door returns what a
board needs and nothing else: the hostname, uptime, load, memory and swap, the disk in the
most trouble, and the list of tmux sessions with which of them is ringing. No file, no
shell, no writes, no proxy. Asked for anything else it answers 403 rather than 401,
because the token is real and the request is not.
It is refused a WebSocket outright, which is the one that would matter.
Argus will not start if a watcher token is the same as the main one, or shorter than 16 characters. Both mistakes turn the weak key back into the strong one.
By default a watcher token can only look. It can be allowed to start and stop things too, and the way that is done is the whole reason it stays safe: no command ever arrives in a request.
The machine publishes a list of names. A board may ask for one of those names and nothing else โ there is no field in any request where a shell line could go, and there never will be.
runnable:
- name: nightly
run: python3 nightly.py # this is only ever read from here
cwd: /srv/work
watchers:
- name: panoptes
token: <the weak one>
may_run: true # otherwise the token stays read-only
may_stop_argus: false # a separate permission; see below-
GET /api/runnableโ the names, and whether each is up. Not the commands: a board has no use for the shell line, and every extra thing on the wire is one more thing a leaked board leaks. -
POST /api/runnable/<name>/<start|stop>โ does it.stopkills that session by exact name, and only a session on the list can be killed, so a board can never touch the work you did not write down. Asking twice is not an error.
Anything else still answers 403. With both permissions on, that key is refused files, sessions, the machine page, the config, ports, documents and every write โ so the worst anything holding your board's config can do is start or stop what you listed.
may_stop_argus allows one more thing, and it is the only action that cannot be reversed
from the board: afterwards nothing is listening there.
Every tmux session carries on untouched โ Argus attaches as a client, it is not their parent โ but the way back to the page is a shell on that machine, or whatever supervises it. The reply says so, and the board asks twice before sending it.
It stands on its own: a machine with nothing worth publishing as runnable can still be one
you want to be able to switch off.
Two boxes on one wire where only one direction is open is the case announcing exists for, and there the same two actions arrive in the reply to the machine's own announcement โ the only channel there is.
obey_board: true # off by default
board_may_stop_argus: falseobey_board is deliberately not implied by report_to: telling a board what you are doing
is not agreeing to take orders from it. Even on, the answer is bounded by the same list โ
a reply can name one of your runnable entries, or ask for the server itself, and nothing
else.