fix(pod): send the token-mint secret over the pod's unix socket, never TCP - #9051
Conversation
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The "server half" already exists on base ( Not justified as shipped
What this change shipsInventory (5 items) — 3 justifiedIntent: stop the pod's
[FIRST-PRINCIPLES-REVIEWED] c754a83 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've verified the load-bearing mechanics against the source: No findings. [OPUS-REVIEWED] c754a83 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Verify one live mint against an older pod gateway that predates unix-socket admission — the 403 remediation branch only runs on mixed versions CI never exercises. [DESIGN-REVIEWED] c754a83 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
a617b3b to
0242dd9
Compare
0242dd9 to
e4bb192
Compare
e4bb192 to
3e40189
Compare
|
Rebased onto main Clean rebase — no conflicts, despite Gates run locally on the changed files only: Please review the rebased branch. Note that a maintainer push makes the maintainer the last pusher, so under this repo's last-push rule a second approver is needed before merge. The two open review items (the GPT lane's test-cleanup finding at |
30537b5 to
cad6de5
Compare
|
Rebase-only push: Main advanced and this branch went Verified locally after the rebase: isort/black/flake8 clean, mypy clean on the three touched source files, and the PR's targeted suites pass (716 passed, 1 skipped). Single squashed commit preserved with original authorship and the Kiro Crew co-author trailer. Auto-merge remains armed; fresh CI + review round is running on the new head. |
cad6de5 to
280842e
Compare
…r TCP (kirodotdev#8552) The pod token mint used to be the one caller that sent the pod's .local_secret over loopback TCP. The port is ordinary loopback: any local user can bind it the moment the pod releases it, and TCP has no peer-credential API to tell a squatter from the gateway. The mint now rides the pod's private unix socket (0600 inside a 0700 owner-only home) with no TCP fallback, and verifies at connect time that the process answering the socket is the attested gateway pid before any bytes are sent, so a same-UID rebind of the socket path receives nothing. Server-side, /api/token/local admits the AF_UNIX transport via kernel peer credentials (deny-by-default: MISMATCH and UNVERIFIABLE both refuse). Original change authored by javenciu; drive-to-green fixes (rebase, narration rewording, import hoisting, shared-helper reuse, version-skew 403 error text) applied on top. Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
280842e to
c754a83
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed the full production diff on c754a83. POSIX上的pod令牌换取改走Unix socket,并在发送前核实对端进程。
loopback_http及服务端接纳逻辑相对基线未改变。main对pod/runtime的修改仅在D-Bus地址解析和故障提示,未改动本PR的mint_token、pod_api、对端验证协议。Windows保留原有端口所有权确认后的TCP路径。 本轮已合并项仅为已审查且归档的授权变更;写前逐文件对照证明本项生产文件及文档自阶段A没有变化。
Fresh REST checks: current-head PR Readiness success; no effective change request or unresolved review hold; CODEOWNERS/UX eligibility and the distinct last pusher verified. Optional baseline failures, if present, remain recorded and are not claimed green. This approval does not bypass branch protection.
Problem / Motivation
mint_tokenreads the pod's.local_secretand sends it in anX-Local-Secretheader over loopback TCP tohttp://127.0.0.1:<port>/api/token/local. Ownership is attested (OWNER_PODpositive proof) and then a separate TCP connection carries the secret — a pod exiting inside that window frees the port for any local user to bind, and loopback TCP has no peer-credential API, so nothing on the wire can tell the squatter from the pod's gateway. #8218 closed exactly this class for the token-bearing request;mint_tokenis the residual its review disposition called out, and it could not ride #8218 because the server refuses the better transport:api_token_localgates onis_loopback(request.remote), and anAF_UNIXrequest has an emptyrequest.remote, so the endpoint answers 403 to the transport that is strictly harder to reach than loopback TCP.Why it matters
The captured value is the pod's per-gateway-start
.local_secret. The impact is bounded exactly as the issue bounds it (capture-now / replay-before-any-gateway-restart, and #8218's socket transport means a fabricated token is only ever handed to the pod's own socket) — but pods carry ~2h TTLs and are torn down routinely, so the port-release window is a normal lifecycle event, not an anomaly. A secret-bearing request should not depend on winning a race against ordinary pod churn.What changed (motivation → approach → change)
The secret now rides a connection whose peer the kernel vouches for, end to end. Three pieces, client / transport / server:
src/kiro_crew/pod/runtime.py):mint_tokensends the mint over the pod's privateAF_UNIXsocket viaunix_socket_urlopen(req, timeout=5, socket_path=pod_socket_path(cfg, name, port), verify_peer=...). That opener has no TCP handler, so "no fallback" is structural rather than a flag: a missing, stale, or refusing socket raises instead of handing the header to whatever answered — the error says explicitly that the call is not retried on127.0.0.1:<port>and why. An HTTP 403 over the socket gets its own message that separates the two causes an operator can hit: a pod worktree whose gateway predates unix-socket admission on/api/token/local(remedy named in the error: update the pod's worktree, then restart it) versus a genuine secret rejection. The URL keeps the loopback host so the gateway's Host validation sees exactly what it saw on TCP; the socket path is derived, never caller-supplied. TheOWNER_PODpre-check stays: it costs one process lookup and buys refusal messages that name why the pod cannot answer. On Windows the mint keeps the OWNER_POD-attested loopback transport: CPython there has noAF_UNIX, so the pod binds no socket, and theIS_WINDOWSbranch returns before any unix-socket machinery is built — the strongest transport the platform offers, unchanged in behavior.src/kiro_crew/loopback_http.py,src/kiro_crew/pod/runtime.py):unix_socket_urlopenaccepts averify_peerconnect-time callback that runs on the connected socket before any HTTP bytes._attested_gateway_verifierbuilds that check from the recorded gateway pid vs the kernel's peer credentials (SO_PEERCREDon Linux,LOCAL_PEERPIDon macOS), and re-proves the freshness-checked record on the connected socket itself — so a pid recycled between the record read and the connect cannot attest: the socket path lives in an owner-writable directory, so a confined same-UID process can unlink it and bind its own listener there — a fresh pid record proves the gateway is alive, not that it is the process answering the file. Deny-by-default: a mismatched peer, an unreadable peer, an unprovable pid record, and a record that cannot be re-proven at connect time all refuse before the request line goes out. Both send sites are covered (token mint and pod api — a rebind between the two sends would otherwise capture a live credential), and the stub-gateway fixture attests itself, so every pod-api test drives the real kernel check end to end.src/kiro_crew/dashboard/handlers/core.py):api_token_localadmits kernel-verified unix peers via_unix_peer_is_self— the sharedtoken_auth._unix_request_socketdiscriminator (one definition of "arrived on the dashboard's unix socket" for the CSRF and token-auth layers) pluscheck_peer_is_self(sock) is PeerCredResult.MATCH, both imported at module level. Because this endpoint istoken_auth-bypassed, admission is deny-by-default:MISMATCH(another principal reached our socket — precisely when the0700directory gate has failed and refusing matters most) andUNVERIFIABLE(no platform mechanism, failed syscall) are both refused, so a platform without peer credentials never silently widens the gate. This admits a transport, never a caller: theX-Local-Secretcheck downstream is unchanged on both transports.health()/ probe stay on TCP — they carry no credential, andport_owner()already reports who replied. The four siblingX-Local-Secretsends against the MAIN gateway incli_server.pyalso stay on loopback TCP: the main gateway is out of this PR's scope (pod mint only), and migrating those sites onto the gateway's unix socket is tracked in Send X-Local-Secret over the main gateway's unix socket in cli_server.py (deferred from PR #9051) #11091. The docker guide (docs/guides/docker.md) local-bootstrap paragraph describes the two admitted transports.flowchart LR subgraph Before m1[mint_token] -->|X-Local-Secret over loopback TCP| p1[whoever holds :port] end subgraph After m2[mint_token] -->|X-Local-Secret over AF_UNIX| v2{peer pid == attested gateway?} v2 -->|MATCH| g2[pod's gateway] v2 -->|MISMATCH / UNVERIFIABLE| r2[refused, zero bytes sent] end classDef added fill:#DCFCE7,stroke:#16A34A; classDef removed fill:#FEE2E2,stroke:#DC2626,stroke-dasharray:4 3; classDef ctx fill:#E0F2FE,stroke:#0284C7; class m2,v2,r2 added class p1 removed class m1,g2 ctx🟩 added · 🟥 removed · 🟦 unchanged
The secret leaves the process only after the kernel names the listener, so a squatter on the port — or on the socket file — receives nothing.
Tests
Server half (
test/test_dashboard_handlers_core_coverage.py,TestLocalToken):test_unix_peer_match_with_valid_secret_issues_a_token— kernel MATCH + valid secret mints (the benign path is admitted, not just attacks refused).test_unix_peer_still_needs_the_secret— admitted transport, wrong secret → 403invalid secret(notloopback only), pinning that admission never weakens the secret check.test_unix_peer_uid_mismatch_is_refused_even_with_the_secret— kernel says another principal: refused with the audit log recordingnon-loopback.test_unix_peer_unverifiable_is_refused_even_with_the_secret— no peer-credential mechanism: refused (deny-by-default pin).Client half (
test/test_pod.py):test_mint_token_sends_the_secret_only_over_the_pod_socket— asserts the TCP opener list stays empty, the unix opener gotpod_socket_path(...), and theX-Local-Secretheader rode the socket.test_mint_token_uses_attested_loopback_on_windows— withIS_WINDOWStrue, the mint rides the loopback opener with the secret header, and building the unix socket path, the unix opener, or the peer verifier each fails the test outright.TestMintPeerVerification— a live in-process listener answering the pod's derived socket path with a mismatched attested pid observes ZERO bytes (the refusal precedes the request line), and the same listener attested as this process completes the mint through the real peer-credential read — no monkeypatch on the kernel path.test_verifier_refuses_when_attestation_expires_before_connectpins the connect-time re-proof: a record valid when the verifier is built but gone at connect time refuses even when the numeric peer pid matches.test_mint_token_reads_secret_and_postsrides the socket transport;test_mint_token_refuses_a_foreign_port_holderpasses unchanged on both sides (control: the pre-check's refusal behavior is untouched).Fixture half (
test/test_pod_api.py): the stub gateway records its own pid with a live start identity, so pod-api tests exercise the verification path rather than bypassing it.Targeted files: 696 passed / 1 skipped. Scoped backend suite (721 related targets): green apart from 15 host-environment failures that reproduce byte-identically on pristine
origin/main(doctor probes, a dev-fleet path assertion, member-context routing). mypy clean over 1539 files; flake8 / isort / black-baseline / comment-history / docs-lint all pass.Manual verification
N/A — unit coverage sufficient: the two new seams are unit-pinned with the kernel verdict and transports faked, and both underlying primitives carry their own pre-existing real-socket suites (
test_pod_api.pybinds a liveAF_UNIXserver againstunix_socket_urlopen;test_socketsec*.pycoverscheck_peer_is_selfincluding real-socket SO_PEERCRED). CI exercises the composed path.Screenshots / video
N/A — backend transport and error-message change only; no user-visible dashboard or app state changes.
Related Issues
Fixes #8552
Pattern harvest
Rule candidate: review-prompt
Pattern: "attest-then-send across separate connections is a TOCTOU on the transport — a credential-bearing request must ride a connection whose peer is verified at send time (unix socket + peer creds), not a port whose owner was checked moments earlier". Flag any request that sends a secret over plain loopback TCP when a peer-verified unix transport exists for the same server.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)