Skip to content

Security Model

Roy Padina edited this page Aug 29, 2026 · 15 revisions

Security Model

Read this before you put a jam on anything but a LAN you own.

The short version: admission is real, and once somebody is in, everybody is equally trusted. Some of the guards below are enforced by code and some are only instructions to a language model. This page says which is which, because planning around the wrong one is how people get hurt.


Gates that are real

These are enforced in code, have tests, and a refusal carries its own reason.

Admission

  • Token — 8–64 characters of [A-Za-z0-9_-], compared at join time. A wrong token does not reject: it falls through to a knock, so rotating a token strands nobody.
  • Knock — the host answers per person, live. Knocks expire after 2 minutes, at most 10 wait at once, and two live participants can never share a name (attribution is by name).
  • Invite linkcjam1_<base64url>, carrying a name, a per-invite secret, the addresses and an expiry. The daemon stores only a hash of each secret, in the 0700 state dir. A link is individually revocable, name-bound, expiring and countable (--uses).
  • Invite-only — knocks are refused outright, with the reason.
  • Anything wrong with a link (tampered, expired, revoked, used up, name already connected) is said out loud and falls through to a knock. A cjam2_… link from a future format is a clean "update claude-jam", not a parse error.

The approval ladder

Every guest-initiated action — a claude command, a file, an export, a permission answer — is default deny, one request in flight per person, expiring after two minutes. always grants standing approval that lives in daemon memory and dies with the daemon; it is never written down.

/exit, /clear and /resume are a hard host-only list, enforced server-side. No guest request path, no always, ever — they would end or wipe the session for everybody.

Exactly three of claude's own commands run for a guest with no round trip: /cost, /status, /context, and only bare. /cost --json is an argument the allowlist has not read, so it asks.

The permission relay

A guest can answer a permission prompt, but never with raw keys:

  1. the daemon reads the prompt's numbered options off the screen;
  2. the guest asks for one digit;
  3. the host approves that digit;
  4. the daemon types that one digit, and only if the screen still says the same thing.

It refuses anything it cannot read cleanly (it wants the picker's own or a question line above the options; a 10-option prompt is more than one digit can pick), and a prompt that changed between the request and the approval is refused rather than answered. The free-text option (Type something.) is raw keyboard access and stays the host's in every mode.

A question is not a permission. claude's own AskUserQuestion picker is a product decision, so anyone may answer it outright. A tool asking to be allowed keeps the ladder above.

Host-only, checked twice

F3 (attach the real TUI) requires host and loopback — the client's own --host flag is a label, not a permission. The same host-plus-loopback gate covers /end, claude-jam end's POST /end, the invite endpoint and the relay switch, all of which additionally require the hook secret out of the 0700 state dir. A secret never travels on an argv (an argv is in ps).

Session ownership

claude-jam ends only a tmux session it created: the session must carry the @claude-jam-owned <state-dir> marker and that directory must hold a session.json naming that exact session back. No name pattern, no filtered sweep over tmux list-sessions, no kill-server, and --all re-verifies every session it touches. Since v0.20 that is structural as well as checked — claude-jam's sessions live on their own tmux socket, so list-sessions there cannot return one of yours even in principle.

Injection

Text reaches the TUI through tmux load-buffer + paste-buffer -p — a file, bracketed paste, never a shell and never an argv. Everything a guest can send is either sanitized (messages, chat, captions: control characters stripped, prefixes neutralised, capped at 20 000 characters) or gated (commands, keys, resize, transfers).


Guards that are only instructions to the model

These are not boundaries. Do not plan around them.

  • "Never reveal the token or an invite link to a bridged participant" and "never claim to have seen /c chat" are sentences in claude's system prompt. A capable model usually follows them. A jailbreak, a confused turn, or a future model that summarises them away does not. What the appended system prompt buys is durability — it survives a /compact that would have summarised the same words out of a hook — not enforcement.
  • The token in claude's context. By default the daemon tells claude the token so it can help a participant. --no-token-in-context keeps it out. If a credential must not leak, run knock-only and mint invite links, rather than telling the agent a shared secret at all.
  • /c chat is human-only because nothing forwards it, which is real — but the agent claiming not to have seen it is the instruction half.

Best-effort, and honest about it

  • Secret masking is a deny-list, not a scanner. It knows five shapes — AWS key ids, PEM PRIVATE KEY blocks, sk-/gh?_-style tokens, bearer credentials, .env-style UPPER_CASE KEY=value — applied to tool calls, tool results, /diff output and every mirror row. Anything else goes through untouched, and on a mirror row a value split across colour escape sequences will not match. It is a seatbelt on the way out, not a boundary. [masked] on somebody's screen means a shape was recognised; the value is unchanged on disk and in claude's context.
  • Export scrubbing is best effort. A transcript is everything claude saw — file contents, tool output, the whole context. claude-jam strips its own token block and the raw token, and nothing else. Run /token new after an export.

Announcing on the local network (mDNS) — what it tells the room

A jam has a name, and by default it announces itself on the local network over DNS-SD so that guests can find it instead of being handed a URL. That is a deliberate disclosure, and this is exactly what it discloses.

What goes on the wire — six fields, and no more:

field example why it is safe to publish
jam reeco debugging the jam's display name (default: the directory's name)
host Roy the host's display name — the same one every guest already sees
id 6d2b8f41 eight characters of the session id, so two jams are tellable apart
access knock / token / invite which KIND of door this is, never the credential
view yes / no whether a browser view exists — not its URL, which contains the view key
v 0.18.0 the claude-jam version

What is never in it: the token, an invite secret, the working directory, any path, the view key, the hook secret. That is enforced by construction rather than by a redaction pass — the record is built from an allow-list of the six keys above, so handing the builder a whole session object still publishes six fields. It is asserted twice: against exactly that object in the unit suite, and against the raw record the real dns-sd hands back — with a token set and an invite minted — in scripts/smoke-discover.mjs.

The disclosure is the jam name and the host's display name. Everyone on the local network learns that this jam exists, what it is called and who is hosting it. Two consequences worth thinking about before you leave it on:

  • A jam name is a project name. reeco debugging on café wifi tells the room who you work for and what is broken. The default is the directory's name, so it is easy to publish a client name or an incident name without meaning to. Rename it: --jam-name "friday".
  • On an untrusted network, turn it off. claude-jam host --no-announce runs the jam exactly as before and says nothing; /menu → Access → Announce on the network flips it while the jam runs. The menu row shows whether the LAN is actually being told, not merely whether it was asked for.

Discovery is not a gate, and it does not bypass one. Finding a jam tells you that it exists and where it is; every gate on this page is untouched. A discovered knock jam still waits for the host to accept you, a discovered token jam still refuses a connection that does not carry the token, and a discovered invite-only jam still refuses a knock outright. The find listing states this on every printing, and the smoke proves it: a real connection to a found token jam, carrying only the address discovery handed out, is refused.

Tunnels are never advertised. mDNS is link-local by design, and a tunnel exists for people who are not on this network. A --tunnel or --funnel URL is handed out by the host, never broadcast.

An advertisement ends with the jam. The registration is a child process of the daemon, and mDNS sends the goodbye when that process goes, so claude-jam end — or any exit path — takes the jam off the network. A daemon that was killed uncleanly leaves the responder to time the record out on its own.


What to assume

  • An invite link is a bearer credential. Whoever holds it is that person as far as claude-jam is concerned. There is no second factor and no device binding. What you get instead is per-person revocation, an expiry, a use count and a name binding — all of which the shared --token lacks. Send links over a private channel.
  • Once in, everybody is equally trusted. An invite grants exactly the same abilities a knock does. There are no per-person capabilities.
  • Revoking or expiring a link disconnects nobody already in. /kick <name> revoke is the pair that removes somebody and takes their link back.
  • Bind is 0.0.0.0. Expose it over Tailscale, a LAN you trust, an SSH tunnel, or a relay whose URL you keep private — never a public IP you advertise. See Remote Access.
  • Being findable is not being reachable, and neither is a permission. mDNS announces a jam that was already listening on that address; --no-announce hides the name, not the port. If you do not want a network reaching the daemon at all, that is a bind and a firewall question, not an announce one.
  • The browser view is read-only but not anonymous: its URL contains the view key as HTTP basic auth (http://jam:<key>@host:7778), so the URL is the credential.
  • bypassPermissions removes the whole permission half of this page. If the host's Claude Code runs with "defaultMode": "bypassPermissions" or --dangerously-skip-permissions, nothing ever asks — so there is no prompt to relay, no host approval, and every participant's message runs tools unattended. Do not share a session in that mode with anyone you would not hand the keyboard to.

Next

Architecture · Remote Access · Files and Export

Clone this wiki locally