-
Notifications
You must be signed in to change notification settings - Fork 0
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.
These are enforced in code, have tests, and a refusal carries its own reason.
-
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 link —
cjam1_<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.
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.
A guest can answer a permission prompt, but never with raw keys:
- the daemon reads the prompt's numbered options off the screen;
- the guest asks for one digit;
- the host approves that digit;
- 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 authority is proven by a local file, not inferred from a network address.
At start the daemon writes <state>/host.key — 32 random bytes, mode 0600, inside the
state dir that is already 0700. Being the host requires two independent conditions, and
either one failing means guest:
-
the key — the client read
host.keyand presented it when it connected; and - the address — the connection started on this machine: a loopback address and an upgrade carrying none of the proxy headers a relay adds.
That is what F3 (attach the real TUI), /end, /kick, /invite, /remote, /announce,
/grants and the browser-view switch all check. The client's own --host flag is a label, not
a permission: it is a claim, and the key is the proof. claude-jam end's POST /end, the
invite endpoint and the relay switch additionally require the hook secret out of the same 0700
dir. No secret ever travels on an argv (an argv is in ps) — the launcher hands its own client
the key's path (--host-key-file), and the client reads the file.
Why a file and not the address. Until v0.34 the address was the whole gate. Every relay
claude-jam offers proxies to http://localhost:<port>, so a socket that crossed the public
internet arrived from 127.0.0.1 — and in 0.21.0 and earlier anybody holding a --tunnel URL was
the host. 0.21.1 closed that by reading the proxy headers a relay cannot suppress, which was
measured against cloudflared and holds. But a header test is a blocklist: it enumerates what a
relay looks like, and the next relay that proxies to loopback without a header on that list
re-opens the hole silently. --funnel was exactly that unknown.
A key cannot be enumerated around. A process on another machine cannot read host.key, whatever
address its packets appear to come from and whatever headers they carry — so a funnel-relayed
socket is not the host whether or not Tailscale sets a header we would have recognised, and the
same is true of any relay added later. The header test is kept, as the second condition:
belt and braces on the gate that owns somebody's machine.
This grants nothing filesystem access did not already grant. Anyone who can read
<state>/host.key can already read token.json beside it, and is already a local user running as
you, with your own privileges — they could simply type into the pane. The key is not a defence
against a local process and was never meant to be. What it stops is the network impersonating
the filesystem.
The key is a credential. It is never logged, never sent back in any frame the daemon builds,
never in a --help example. Only its path is ever printed. It lives and dies with the state dir,
which goes when the jam ends.
Every way out is scrubbed, not just /export (0.22.0). v0.34 scrubbed the key on the export
path only, reasoning that it is never told to claude and so cannot reach a transcript. That
reasoning had a hole, and the 0.22.0 release gate found it: claude runs as the host user with
file tools, so any participant can ask it to read <state>/host.key — and the answer lands on
the pane and in the transcript, both of which the daemon broadcasts. All three now scrub, sharing
one helper:
| way out | scrubbed |
|---|---|
/export |
yes, since v0.34 |
| the transcript → frame path (what everyone's client shows) | yes, since 0.22.0 |
| the mirror rows (the live pane, F2) | yes, since 0.22.0 |
The join token is scrubbed on the same three paths, and in knock mode that is the bigger of
the two: a knock-admitted guest has no token at all, so learning it gains them something they were
never given — persistence across a /token rotation, and the ability to hand out access.
Scrubbing is by known literal, not by pattern. The daemon knows both real values, so it searches for exactly those; a pattern loose enough to catch a bare 64-hex key would also blank out commit shas and checksums on somebody's screen.
One ceiling, stated plainly: a secret wrapped across two rows of the captured pane matches
in neither half, so a 64-hex key on an 80-column pane is half-exposed on the mirror path. The
transcript path and /export see whole text and do catch it. Being the host still needs locality
as well as the key, which is what keeps this a papercut rather than a hole — but if you have made
claude print the key on screen, rotate the jam (claude-jam end, then claude-jam host).
No released version before 0.22.0 is affected by this: none of them has a host key at all.
There is no fallback, and that is deliberate. A client launched with --host against a jam
that has no key file — a daemon from before v0.34, or one started by hand — prints one line and
joins as a guest. Falling back to the address would re-open the 0.21.1 hole for exactly the
people who upgrade without restarting. End the jam and start it again to be its host. When a host
claim is refused, the refusal names which of the two conditions failed, because "you are not the
host on your own machine" is otherwise unanswerable.
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.
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).
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
/cchat" 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/compactthat 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-contextkeeps 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. -
/cchat is human-only because nothing forwards it, which is real — but the agent claiming not to have seen it is the instruction half.
-
Secret masking is a deny-list, not a scanner. It knows five shapes — AWS key ids, PEM
PRIVATE KEYblocks,sk-/gh?_-style tokens, bearer credentials,.env-style UPPER_CASEKEY=value— applied to tool calls, tool results,/diffoutput 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. -
Scrubbing is best effort, and only of claude-jam's OWN two secrets. A transcript is
everything claude saw — file contents, tool output, the whole context. claude-jam strips its own
token block, the raw join token and the host key, on all three ways out (
/export, the transcript → frame path, the mirror rows). It strips nothing else, and a value wrapped across two pane rows is missed on the mirror path. Run/token newafter an export.
The phone tier of a nudge (see Joining a Jam) is the one part of claude-jam that touches a service outside the jam, and it is built so that the jam never learns anything about it.
- The topic is a bearer credential. Anyone who knows an ntfy topic can publish to it — and, on a public server, subscribe to it. Treat it exactly like a password: long, random, and yours.
-
It lives in one place: your own
~/.config/claude-jam/config.json($XDG_CONFIG_HOMEis honoured), read by your own client, at startup. -
Your own client is what POSTs it. The daemon is not asked, is not told, and has no code
path that could carry it: it is never in a frame, never in an invite link, never in the
protocol, never in
token.json, never in a log line and never in a/menurow — the panel saysconfigured, never what. A unit test asserts the topic appears in nothing but the URL of a request that client makes itself. -
The sender cannot reach your phone on purpose. They send
/ping <you>; whether that becomes a buzz is decided entirely on your machine, by your own toggles and your own config. - A failure is silent. One dim line, once per session, and never a retry loop — a phone that did not buzz must not cost the client a frame.
-
What ntfy.sh sees is what you posted: the sender's display name and the nudge message, on a
topic you chose. Nothing about the jam, the host, the project or the conversation. If that is
still too much, use your own ntfy server (
"server": "https://ntfy.example.com"— https only) or simply do not create the file, which is the default.
Idle reporting, which the same feature depends on, is coarse seconds and nothing else: the number of whole seconds since that person last typed or submitted. There is no key, no text and no window title anywhere in that path — a client reports "120", never what was typed 120 seconds ago.
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.21.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 debuggingon 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-announceruns the jam exactly as before and says nothing;/menu → Access → Announce on the networkflips 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.
-
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
--tokenlacks. 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.
-
Being the host is a local file, not a name or an address.
<state>/host.keyis0600in a0700dir, and reading it is what proves the claim; the loopback test is kept as a second, independent condition. It is a floor, not a ceiling: it says the network cannot become the host, and nothing more. Any local process running as you can read it — but it can already readtoken.json, so that is not a new boundary. It is not device binding either: the key protects host authority, not the machine. -
Revoking or expiring a link disconnects nobody already in.
/kick <name> revokeis 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-announcehides 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. -
The upload policy is a prompt setting, not a protection.
--uploads autoremoves the question, never a guard: the sanitized basename, the traversal refusal, the 20 MB per-file cap, the one-transfer-in-flight rule, the<cwd>/jam-uploads/confinement, "nothing executed, nothing opened" and the announced-vs-actual byte check are all checked before the policy is consulted and are identical in all three modes. Whatautodoes change is that anybody already admitted can write files into the host's project directory without being asked each time — so turn it on for a room you already trust, and remember the session quota (40 files / 200 MB) is what stops it filling a disk.--uploads offrefuses everybody, including the host's own/paste. See Files and Export. -
--exportis separate and staysaskon purpose. A transcript is the whole conversation — every file claude read, all its tool output, its entire context — so--uploads autosays nothing about it, and after any export you should run/token new. -
A nudge is not a capability. Anyone in the jam may
/pinganyone else, on purpose: it is bounded by a 30-second-per-target rate limit, it is visible to the whole room, and it carries no ability to run anything. It is the one thing in claude-jam that is deliberately not on the approval ladder. -
bypassPermissionsremoves 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.