You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #667 per operator decision (2026-08-14/15). Both holes are live in shipped code today — not introduced by the control-channel work, and dependent on nothing in it. Keeping them inside #667 would gate a security fix behind a multi-slice feature.
(a) opencode crews run an unauthenticated HTTP control server on 127.0.0.1
packages/core/src/crew-spawn.ts:388 reserves a free port and packages/agents/src/drivers/opencode.ts:29 launches the crew as opencode --port <N>. OPENCODE_SERVER_PASSWORD is set nowhere in this repo (grep -rn OPENCODE_SERVER_PASSWORD packages/ -> no matches). opencode itself warns on boot that the server is unsecured.
That port is the full session-control surface. Verified live on the wire 2026-08-13 against a throwaway opencode spawned exactly the way squadrant spawns crews:
POST /tui/append-prompt {text} -> 200 true
POST /tui/submit-prompt -> 200 true
POST /session/{id}/prompt_async {parts:[{type:text,text}]} -> 204
POST /session/ses_doesnotexist/prompt_async -> 404 NotFoundError
Impact: any local process — an npm postinstall script, a browser extension with localhost access, another agent on the machine — can submit arbitrary prompts to a crew, and therefore run arbitrary commands in that crew worktree under the crew permission mode. No root, no process-tree access; reaching 127.0.0.1:<N> is enough.
Design constraints found while scoping the fix (read before implementing)
Do NOT put the password on the command line.crew-spawn.ts:421-422 builds an envPrefix and sends the whole thing into the pane as a shell line. A secret there leaks three ways: pane scrollback (visible to squadrant crew read), shell history, and ps/process listing — readable by any local process, which is precisely this threat model. Locking the door and taping the key to it.
Use the existing per-crew config file instead.writeOpencodeConfig (crew-spawn.ts:402) already writes a per-crew config passed via OPENCODE_CONFIG=. Put the secret there with 0600. Unverified: whether opencode reads a server password from config or only from env — smoke-test this on the wire before committing to the design. If env-only, export it from a 0600 wrapper script so it never lands on argv.
Daemon restart is already handled — inherit it.serverPort is persisted on the disk-backed task record, and daemon/start.ts:184-190 re-subscribes the SSE bridge from store.listAll() after a bounce. A serverPassword on the same record gets persistence and recovery for free. No new machinery.
Update the SSE bridge in the same change.packages/agents/src/opencode/sse-bridge.ts subscribes to that port; enabling auth without updating it silently kills opencode lifecycle signals.
Migration: crews spawned before this fix have no password on their record. Treat a missing field as legacy and attempt unauthenticated, or every in-flight crew goes dark the moment this deploys.
Side benefit:getFreePort() can recycle a port. Today a stale bridge pointed at a reused port silently drives the wrong crew; with a password it fails auth instead. The secret doubles as a session-identity check — a silent error becomes a visible one.
Captain UX is unaffected. Captain never talks to this port: crew send -> daemon -> sendToPane. The password lives strictly between daemon and opencode. Operator takeover (#649) is also unaffected — that is keyboard into the pane, not HTTP.
(b) Claude captain inbox socket accepts an unattested external sender
Claude captains self-register a session inbox socket. Verified 2026-08-08: a plain Node process — no SendMessage tool, outside the target process tree — wrote one NDJSON line to that socket and it arrived as a genuine user turn.
Under --permission-mode auto (squadrant default for captain and crew; internally "prompting"), an unattested external sender walks the inbound gate all the way to ACCEPT.
Impact: heavier than (a). A crew only edits its own worktree; a captain spawns crews, approves work, and merges PRs. Any local process able to reach the socket can inject a user turn into the highest-authority session on the machine. This is a local privilege-escalation path.
Fix direction, two layers:
0700 on the socket directory — cheap, immediate, closes most of it.
Explicit crossSessionInbound allowlist / attestation, so an inbound peer must be a session squadrant itself registered rather than anything that can guess the socket path. This is the architecturally correct one; layer 1 is a fence.
Confidence — stated plainly
Hole
Verification
(a) opencode
Solid. Two independent layers: code inspection in this repo + live wire test.
(b) captain socket
One live observation only (2026-08-08). The Claude-side inbound gate behaviour is not a promised public contract. Re-verify against the current Claude Code version before designing the fix — it may already be tightened.
Neither is a regression from the #667 spec; both predate it.
Suggested order
(a) first — solid, entirely inside our own code, no third-party behaviour dependency, contained fix. (b) should open with a re-verification step against current Claude Code; if it still gets through, ship the 0700 layer immediately (nearly free) and treat attestation as the follow-up.
#667 replaces screen-scraping inference with these same native channels, and it has good reason to move slowly: it ships behind a per-agent off | shadow | on flag and cuts over on measured disagreement counts rather than a calendar. A live hole should not wait on that schedule. Both fixes are self-contained and land independently.
Full context: docs/specs/2026-08-13-agent-control-channel-design.md
Split out of #667 per operator decision (2026-08-14/15). Both holes are live in shipped code today — not introduced by the control-channel work, and dependent on nothing in it. Keeping them inside #667 would gate a security fix behind a multi-slice feature.
(a) opencode crews run an unauthenticated HTTP control server on 127.0.0.1
packages/core/src/crew-spawn.ts:388reserves a free port andpackages/agents/src/drivers/opencode.ts:29launches the crew asopencode --port <N>.OPENCODE_SERVER_PASSWORDis set nowhere in this repo (grep -rn OPENCODE_SERVER_PASSWORD packages/-> no matches). opencode itself warns on boot that the server is unsecured.That port is the full session-control surface. Verified live on the wire 2026-08-13 against a throwaway opencode spawned exactly the way squadrant spawns crews:
POST /tui/append-prompt {text}->200 truePOST /tui/submit-prompt->200 truePOST /session/{id}/prompt_async {parts:[{type:text,text}]}->204POST /session/ses_doesnotexist/prompt_async->404 NotFoundErrorImpact: any local process — an npm postinstall script, a browser extension with localhost access, another agent on the machine — can submit arbitrary prompts to a crew, and therefore run arbitrary commands in that crew worktree under the crew permission mode. No root, no process-tree access; reaching
127.0.0.1:<N>is enough.Design constraints found while scoping the fix (read before implementing)
crew-spawn.ts:421-422builds anenvPrefixand sends the whole thing into the pane as a shell line. A secret there leaks three ways: pane scrollback (visible tosquadrant crew read), shell history, andps/process listing — readable by any local process, which is precisely this threat model. Locking the door and taping the key to it.writeOpencodeConfig(crew-spawn.ts:402) already writes a per-crew config passed viaOPENCODE_CONFIG=. Put the secret there with0600. Unverified: whether opencode reads a server password from config or only from env — smoke-test this on the wire before committing to the design. If env-only, export it from a0600wrapper script so it never lands on argv.serverPortis persisted on the disk-backed task record, anddaemon/start.ts:184-190re-subscribes the SSE bridge fromstore.listAll()after a bounce. AserverPasswordon the same record gets persistence and recovery for free. No new machinery.packages/agents/src/opencode/sse-bridge.tssubscribes to that port; enabling auth without updating it silently kills opencode lifecycle signals.DeliveryOutcomein the Adopt Claude Code cross-session messaging as ground-truth liveness + delivery (replaces screen-scraping) — Claude-only, needs a driver seam #667 spec.getFreePort()can recycle a port. Today a stale bridge pointed at a reused port silently drives the wrong crew; with a password it fails auth instead. The secret doubles as a session-identity check — a silent error becomes a visible one.Captain UX is unaffected. Captain never talks to this port:
crew send-> daemon ->sendToPane. The password lives strictly between daemon and opencode. Operator takeover (#649) is also unaffected — that is keyboard into the pane, not HTTP.(b) Claude captain inbox socket accepts an unattested external sender
Claude captains self-register a session inbox socket. Verified 2026-08-08: a plain Node process — no
SendMessagetool, outside the target process tree — wrote one NDJSON line to that socket and it arrived as a genuine user turn.Under
--permission-mode auto(squadrant default for captain and crew; internally"prompting"), an unattested external sender walks the inbound gate all the way to ACCEPT.Impact: heavier than (a). A crew only edits its own worktree; a captain spawns crews, approves work, and merges PRs. Any local process able to reach the socket can inject a user turn into the highest-authority session on the machine. This is a local privilege-escalation path.
Fix direction, two layers:
0700on the socket directory — cheap, immediate, closes most of it.crossSessionInboundallowlist / attestation, so an inbound peer must be a session squadrant itself registered rather than anything that can guess the socket path. This is the architecturally correct one; layer 1 is a fence.Confidence — stated plainly
Neither is a regression from the #667 spec; both predate it.
Suggested order
(a) first — solid, entirely inside our own code, no third-party behaviour dependency, contained fix. (b) should open with a re-verification step against current Claude Code; if it still gets through, ship the
0700layer immediately (nearly free) and treat attestation as the follow-up.Why split from #667
#667 replaces screen-scraping inference with these same native channels, and it has good reason to move slowly: it ships behind a per-agent
off | shadow | onflag and cuts over on measured disagreement counts rather than a calendar. A live hole should not wait on that schedule. Both fixes are self-contained and land independently.Full context:
docs/specs/2026-08-13-agent-control-channel-design.md