Skip to content

Security Model

Roy Padina edited this page Aug 30, 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). When you are told about a name clash depends on whether you have authenticated (v0.22.1): a token or invite holder is refused at once and by name, a knocker is not told at all, and their clash is settled at admission — they join as Dana-2 and are told so. See below.
  • 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. Before 0.22.1 the last of those five — name already connected — was the exception: it was said out loud and then the socket was closed 4409. It now falls through like the other four, so somebody whose own laptop woke up and reconnected on the same link while their stale socket was still in the roster is no longer locked out; the host is told which name it clashes with, and approving seats them as Yossi-2, never as a second Yossi.

The link is a blob, and the daemon believes none of it. Driven on 2026-08-30 against a real jam: rewriting the name inside the blob (and separately the name in the hello) admitted the holder under the record's name, never the claim; pushing exp ten years forward changed nothing, because expiry is the daemon's own copy and is re-checked on every use; the same for revocation — a link that worked a second ago is refused the moment /invite revoke runs; truncating or flipping one character of the secret makes it unknown; and two sockets racing one --uses 1 link admitted exactly one, the other used-up. The secret is compared as a SHA-256 digest through timingSafeEqual, so there is nothing to learn from how long a refusal takes.

What the blob's address list is, and is not. It carries up to four ws:///wss:// addresses and no path, query or credential — it is where to try, in order. A hand-made blob could of course point somewhere else, but that is no more than what pasting a ws:// URL already does: a link you were sent is only as trustworthy as whoever sent it, and claude-jam cannot tell you otherwise. The addresses are not a second credential and nothing is authenticated by them.

Authenticate first, then answer anything about the roster (v0.22.1). Until 0.22.0, a hello naming somebody already in the jam was refused the name "X" is already taken here and closed 4409 before any admission — so the roster could be probed name by name with no token and no approval, unlimited: the close happened above the pending set, so the 10-knock cap never applied and every attempt was a fresh socket. Measured 2026-08-30 against a real jam: probing Roy, Dana and Nobody returned 4409, 4409 and a pending knock, which is the whole roster.

The fix is ordering, not silence, because silence would have cost the join UX:

  • a token or invite holder is told at once, by name, exactly as before — they have proved they belong here, and being told is the point;
  • a knocker has nothing to authenticate with yet, so the clash is not answered at all. It is settled when the host lets them in: they join as Dana-2 and are told that is the name their messages are attributed to. After the fix, probing Roy, Dana and Nobody returns the identical {state: 'pending'} — indistinguishable;
  • the host is told, on the frame they approve from: ⚑ Dana wants to join (…) — "Dana" is already here, so they will join under another name. Without that line an unauthenticated stranger could make the approval bar read a name that is in the room — the host's own — with nothing saying it was not them.

Invite-only mode never leaked it, because its refusal comes first. And nothing else is available pre-approval: a waiting knocker holds exactly one {state: 'pending'} frame, with no jam name, no host name, no cwd, no session id and no token.

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. All of that was driven adversarially on 2026-08-30 and held: 0, 10, 01, -1, 1.0, 1;2, yes, an arrow-key escape sequence, an Arabic-Indic digit, a vulgar fraction, a boolean, a number, an array and an object were each refused by name, a digit for an option that is not on screen was refused with the count, and an answer approved after the screen moved on typed nothing and said why. The only keys the pane received across all of it were the one legitimate digit and its Enter.

The free-text option (Type something.) is raw keyboard access and stays the host's in every mode — and since 0.22.1 the text is reduced to one line with no control characters before it is typed. It had not been: it was the one participant text that skipped stripControl, so a carriage return in it submitted claude's field and typed the rest as a second, unattributed prompt — a line the agent reads as the host speaking. The host approves what the approval bar shows, and a control byte does not show, which is what made it worth fixing rather than documenting.

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.

Who is the host — a file on the host's disk (v0.34)

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:

  1. the key — the client read host.key and presented it when it connected; and
  2. 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.

On Windows there are no mode bits, and claude-jam does not pretend otherwise (v0.32 W1). 0600 and 0700 are POSIX; on Windows the same files and directories get an NTFS ACL: icacls <path> /inheritance:r /grant:r <DOMAIN\user>:F, plus (OI)(CI) on a directory so what is created inside it inherits the grant. /inheritance:r is the load-bearing half — it drops the entries the path inherited from its parent. Read it back with icacls <path> if you want to see it.

A file reduces to one entry; a directory does not. That distinction is measured, not intended — the first Windows CI run this project ever had (2026-08-30) is what found it. On a file the grant list comes back as exactly the current user. On a directory the same call exits 0 and the list is the owner plus NT AUTHORITY\SYSTEM plus BUILTIN\Administrators, and applying it a second time changes nothing. Those two principals can read anything on the machine whatever a DACL says, so this is a weaker guarantee rather than a hole — and CI now asserts the true one: the owner is granted, and no principal beyond those three (no Users, no Everyone, no Authenticated Users) ever appears.

Two honest caveats. If the ACL cannot be applied (no %USERNAME%, icacls missing), claude-jam does not fail the write: the file keeps the ACL it inherited from %APPDATA% or %TEMP%, which is inside your own profile — a degradation, not an exposure. And this path is verified only by CI on a windows-latest runner, which writes a real file and reads the real ACL back; no human has watched it work. There is also no Windows host yet, so today the only file this applies to on Windows is a client's own input history — and no state directory is created there at all, which is why the directory finding above changes nothing that ships today.

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
…and all three carry the hook secret too since 0.23.1 — see below

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 the 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.

The third secret, and why the list is now a registry (0.23.1)

Two needles maintained by hand is how the next one got missed. The hook secret (JAM_HOOK_SECRET) authenticates the daemon's local control endpoints — admitting a knocker, ending the jam, minting an invite, switching a public relay on, dispatching a peer task, and every hook callback — and it is written to <state>/session.json as the lower-case field "secret", which is the one shape the pattern masker deliberately ignored (its .env rule is upper-case-only so it does not blank out const token = … in every diff you watch). So the exact route described above for host.key leaked this one instead: measured 2026-08-30, a guest's mirror frame carried "secret": "…" in clear on a screen where the join token and the host key on the adjacent rows were both masked. Holding it was sufficient on its own — no host key, no join token — to reach those endpoints from a socket that looks local. The --tunnel path was already shut by 0.21.1's proxy-header check; a plain SSH tunnel is not.

Fixing the needle alone would leave the fourth credential in the same position, so the list stopped being hand-written. There is now one registry of every secret a daemon holds, with its mask and its validator; all four scrub funnels iterate it; host.mjs has a single expression that builds the secrets object and every funnel call uses it. Two tests are the forcing function — one walks the registry and asserts every entry is masked on every funnel, including every boundary the value can wrap at; the other lints host.mjs so a funnel cannot be handed a hand-picked subset. A fifth secret that is not registered fails a test instead of appearing on somebody's screen.

Invite secrets are deliberately not registered, and that was checked rather than assumed. They are the obvious fourth candidate, and the answer is counter-intuitive: only inviteHash(secret) is persisted, so the plaintext exists in exactly one frame — the /invite reply to the host who asked — and is then dropped. Measured on a real jam after minting a link, the plaintext was in none of the eight files in the state dir (invites.json holds id, hash, name, uses, maxUses, expires, revoked and createdAt, and no secret), not in the daemon log, and not on the pane. The route that leaked the hook secret cannot reach an invite secret at all.

Registering one would make things worse. A needle only masks a value the daemon still holds, so the daemon would first have to start retaining every live invite's plaintext — creating the exposure in order to close it. A discarded secret cannot be printed, which is the stronger property, and it is why the registry has three entries rather than four.

The pattern masker also learned the quoted-JSON shape, case-insensitively, since that is what hid this. The unquoted .env rule stays upper-case-only on purpose: this runs on a code screen.

The wrap is closed as of 0.22.1, and it was worse than "a ceiling". 0.22.0 scrubbed one captured row at a time, so a secret wrapped at the right margin matched in neither half. That was recorded as an accepted ceiling until it was measured: the split probability for a value of length L on a W-column pane is (L−1)/W, so the 64-hex host key splits 79% of the time at 80 columns, 63% at 100, and always on a pane narrower than 64. A real jam, an 80-column pane and a real mirror guest handed over the whole key in two adjacent rows.

scrubRowJoins now runs on the raw rows before the per-row pass, on both pane funnels (the live mirror and screen-history), and matches across the join: a row that ends with a prefix of a secret whose remainder begins the next row (or spans several, which is certain on a narrow pane) is masked. What remains is only a value with an escape sequence inside it, which is the deny-list masker's own documented ceiling.

Being the host still needs locality as well as the key, which is what kept the original leak 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.

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.
  • 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. A value wrapped across pane rows used to be missed on the mirror path and is caught since 0.22.1; a value with an escape sequence inside it still is not. Run /token new after an export.

The ntfy topic stays on your machine

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_HOME is 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 /menu row — the panel says configured, 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.


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.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 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.

And find does not authenticate anybody — this is the direction that bites (fixed in 0.23.1). mDNS is unauthenticated by construction: no signature, no identity, nothing to check. Anybody on your network can publish a jam that looks exactly like somebody else's. Reproduced 2026-08-30 with a dns-sd registration of one's own claiming another jam's name, another host's name, access=token and view=yes: it listed beside the real jam and matched it in every displayed column except the address — and under it, claude-jam find printed a join command containing --token <token>, while the launcher made that a one-keypress pick whose preview echoed the real token to the terminal. A printed command is an instruction, so that line was the vulnerability regardless of what the human did next. Affected 0.19.0 (where find shipped) through 0.23.0.

What changed, and what it means for you:

  • No printed join command carries --token any more. Discovery is an address hint and never a destination for a credential.
  • The address leads every row — in the find table and in the launcher's Join screen. It is the only field on a discovered row an attacker cannot forge into a match. It used to be last.
  • Every listing says so, once, under the table: an advertisement is unauthenticated, anybody on this network can publish one, the address is the only field that cannot be faked.
  • For a token jam, use an invite link. A cjam1_… link carries a per-invite secret bound to the host's own addresses, so a look-alike host cannot replay one — it is the safe way to admit somebody who found you on the network. See Joining a Jam.
  • If you join by URL and type a token, confirm the address with the host out of band first. The launcher's token field names the address the token is about to be sent to, and the command it prints shows <your token> rather than the value.

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.
  • Being the host is a local file, not a name or an address. <state>/host.key is 0600 in a 0700 dir, 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 read token.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> 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, enforced on the tmux client — and its URL is the credential. Two things to know:
    • The URL is a credential, and when a token is set it IS the join token. The view key is the join token whenever one exists (one secret, so a friend needs only one thing), so a leaked view URL is a leaked join link — send one only to somebody you would let into the jam. /token new rotates both at once and kills the old view URL; /kick does not — it closes a socket and changes no key, so someone you kick keeps the browser view until you rotate or turn the view off.
    • Read-only means read-only as of 0.23.1, and did not before. Every earlier version rested the claim on ttyd's own default, which is version-dependent (ttyd 1.6.x and earlier are writable with no flag) — and ttyd honours a resize even when it refuses input, so a viewer could drag the host's live claude pane down to an unusable size from anywhere, including through a public relay. Both are closed by making the viewer's grouped tmux session read-only,ignore-size, which holds whatever --view-ttyd points at. scripts/smoke-view.mjs proves it from the wire, including a step that deliberately runs a writable ttyd.
  • The upload policy is a prompt setting, not a protection. --uploads auto removes 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. What auto does 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 off refuses everybody, including the host's own /paste. Two of those guards were repaired in 0.22.1, both found by the 2026-08-30 review: the confinement is a name filter, and a name filter cannot see a filesystem — a dangling symlink planted at jam-uploads/<name> read as "that name is free" (existsSync follows links) and the write went through it, landing a guest's bytes outside the directory with nobody told. The write is now exclusive (O_CREAT|O_EXCL), which refuses a symlink whether or not its target exists. And the quota was read from what had landed while "one at a time" is per socket, so several clients firing in the same tick were all granted: measured, four clients against a 2-file quota wrote four files with no approval. It now counts the grants in flight too. See Files and Export.
  • --export is separate and stays ask on purpose. A transcript is the whole conversation — every file claude read, all its tool output, its entire context — so --uploads auto says nothing about it, and after any export you should run /token new.
  • A nudge is not a capability. Anyone in the jam may /ping anyone 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.
  • 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