Skip to content

fix(pod): send the token-mint secret over the pod's unix socket, never TCP - #9051

Merged
NicholasRBowers merged 1 commit into
kirodotdev:mainfrom
javenciu:fix/pod-mint-token-unix-socket
Sep 18, 2026
Merged

NicholasRBowers merged 1 commit into
kirodotdev:mainfrom
javenciu:fix/pod-mint-token-unix-socket

Conversation

@javenciu

@javenciu javenciu commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

mint_token reads the pod's .local_secret and sends it in an X-Local-Secret header over loopback TCP to http://127.0.0.1:<port>/api/token/local. Ownership is attested (OWNER_POD positive 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_token is the residual its review disposition called out, and it could not ride #8218 because the server refuses the better transport: api_token_local gates on is_loopback(request.remote), and an AF_UNIX request has an empty request.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:

  • Client (src/kiro_crew/pod/runtime.py): mint_token sends the mint over the pod's private AF_UNIX socket via unix_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 on 127.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. The OWNER_POD pre-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 no AF_UNIX, so the pod binds no socket, and the IS_WINDOWS branch returns before any unix-socket machinery is built — the strongest transport the platform offers, unchanged in behavior.
  • Transport (src/kiro_crew/loopback_http.py, src/kiro_crew/pod/runtime.py): unix_socket_urlopen accepts a verify_peer connect-time callback that runs on the connected socket before any HTTP bytes. _attested_gateway_verifier builds that check from the recorded gateway pid vs the kernel's peer credentials (SO_PEERCRED on Linux, LOCAL_PEERPID on 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.
  • Server (src/kiro_crew/dashboard/handlers/core.py): api_token_local admits kernel-verified unix peers via _unix_peer_is_self — the shared token_auth._unix_request_socket discriminator (one definition of "arrived on the dashboard's unix socket" for the CSRF and token-auth layers) plus check_peer_is_self(sock) is PeerCredResult.MATCH, both imported at module level. Because this endpoint is token_auth-bypassed, admission is deny-by-default: MISMATCH (another principal reached our socket — precisely when the 0700 directory gate has failed and refusing matters most) and UNVERIFIABLE (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: the X-Local-Secret check downstream is unchanged on both transports.
  • Deliberately unchanged: health() / probe stay on TCP — they carry no credential, and port_owner() already reports who replied. The four sibling X-Local-Secret sends against the MAIN gateway in cli_server.py also 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
Loading

🟩 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 → 403 invalid secret (not loopback 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 recording non-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 got pod_socket_path(...), and the X-Local-Secret header rode the socket.
  • test_mint_token_uses_attested_loopback_on_windows — with IS_WINDOWS true, 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_connect pins 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_posts rides the socket transport; test_mint_token_refuses_a_foreign_port_holder passes 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.py binds a live AF_UNIX server against unix_socket_urlopen; test_socketsec*.py covers check_peer_is_self including 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

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@javenciu
javenciu requested a review from a team as a code owner September 6, 2026 15:54
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of c754a83f62aa7a19208fc87da64a7f5fa48087a8 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

First-Principles-Verdict: CONCERNS

The "server half" already exists on base (core.py:2463); this diff ships only its docstring and tests — and app_lifecycle_client's two unix sends keep the rebind gap open.

Not justified as shipped

  • Item 5 — rides along: the description says "the server refuses the better transport … answers 403" and lists Server as one of "Three pieces" changed, but base src/kiro_crew/dashboard/handlers/core.py:2463 already admits kernel-verified unix peers; the diff's only server hunk is a docstring.
    Clears when: the description credits the admission to base behavior instead of this change.
  • Item 2 — symptom-level (point patch: 2 unfixed siblings): verify_peer covers only the pod's two sends, while app_lifecycle_client.py:111 and :139 send X-Local-Secret and a minted token over unix_socket_urlopen with no peer check (grepped unix_socket_urlopen: 4 src call sites) — the same owner-writable-path rebind the PR's own threat model names; the declared deferral (Send X-Local-Secret over the main gateway's unix socket in cli_server.py (deferred from PR #9051) #11091) covers only the four cli_server.py TCP sends.
    Clears when: a verifier on those two sends, or those sites counted into the tracked deferral.

What this change ships

Inventory (5 items) — 3 justified

Intent: stop the pod's .local_secret from riding a rebindable loopback TCP port during token mint — a FIX.

  1. Pod token mint sends its secret only over the pod's own unix socket (POSIX) — justified
  2. A kernel peer-pid check refuses a rebound socket before any bytes, on both pod send sites — symptom-level (point patch: 2 unfixed unix-send siblings in app_lifecycle_client.py)
  3. Windows mint unchanged: ownership-attested loopback TCP — justified
  4. Mint failures now name the socket, refuse a TCP retry, and a 403 separates stale-worktree from bad-secret — justified
  5. Server unix-peer admission ships only as docstring, docker.md text, and tests — rides along: base already admits it; description presents it as this PR's server change

[FIRST-PRINCIPLES-REVIEWED] c754a83

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed c754a83f62aa7a19208fc87da64a7f5fa48087a8 via the fork AI-review pipeline; updated in place on each push.

Review details

I've verified the load-bearing mechanics against the source: PodError/PodOwnershipUnproven subclass RuntimeError (not OSError), so a verify_peer refusal propagates unwrapped past the except (URLError, OSError, ValueError) clauses as the intended legible refusal; the except BaseException in connect() closes the fd and leaves self.sock unset before re-raising; verify_peer runs on the exact connected fd that carries the bytes (no TOCTOU/reconnect); get_peer_pid handles Linux SO_PEERCRED/macOS LOCAL_PEERPID and returns None on failure, which _verify treats as refusal (deny-by-default); the Windows branch in mint_token avoids AF_UNIX entirely, and pod_api's unix-only transport is pre-existing, not introduced here. I could not ground any concrete input → call path → wrong outcome on the changed lines.

No findings.

[OPUS-REVIEWED] c754a83

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Design-level review of c754a83f62aa7a19208fc87da64a7f5fa48087a8 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed c754a83f62aa7a19208fc87da64a7f5fa48087a8 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c754a83

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 6, 2026
@javenciu
javenciu force-pushed the fix/pod-mint-token-unix-socket branch from a617b3b to 0242dd9 Compare September 6, 2026 18:55
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 6, 2026
@javenciu
javenciu force-pushed the fix/pod-mint-token-unix-socket branch from 0242dd9 to e4bb192 Compare September 6, 2026 20:32
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 6, 2026
@bolichen97
bolichen97 force-pushed the fix/pod-mint-token-unix-socket branch from e4bb192 to 3e40189 Compare September 8, 2026 10:11
@bolichen97

Copy link
Copy Markdown
Collaborator

Rebased onto main 6fbb06bcd by a maintainer as part of the 2026-09-08 open-PR audit.

Clean rebase — no conflicts, despite #8528 (ee3b8ebb3) having rewritten large parts of src/kiro_crew/pod/runtime.py after this branch's old merge base. Both commits replayed unchanged and the diff is byte-identical in shape to before (7 files, +494/-28).

Gates run locally on the changed files only: black --check, isort --check-only, flake8 all clean; pytest test/test_pod.py test/test_pod_api.py test/test_dashboard_handlers_core_coverage.py → 667 passed, 1 skipped.

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 test/test_pod.py and the First Principles _unix_request_socket reuse suggestion) are untouched and still yours. Reply if anything looks wrong.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 8, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 15, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/pod-mint-token-unix-socket branch from 30537b5 to cad6de5 Compare September 17, 2026 16:53
@NicholasRBowers

Copy link
Copy Markdown
Contributor

Rebase-only push: 30537b50ccad6de50d (conflict resolution, no code change)

Main advanced and this branch went CONFLICTING. Rebased onto current main (2a138cc4c). One conflict, in src/kiro_crew/dashboard/handlers/core.py: #10653 independently landed _unix_peer_is_self on main with a one-line docstring while this PR carries the full reviewed docstring for the same function. The function body and plumbing are byte-identical between the two, so the resolution keeps this PR's docstring over main's placeholder — the core.py delta shrinks from 40 to 21 lines (docstring-only now), and every other file's diff is unchanged (7 files total, same as the reviewed head).

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.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: checking Automated validation is still running labels Sep 17, 2026
@NicholasRBowers
NicholasRBowers force-pushed the fix/pod-mint-token-unix-socket branch from cad6de5 to 280842e Compare September 17, 2026 19:16
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 17, 2026
…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>
@NicholasRBowers
NicholasRBowers force-pushed the fix/pod-mint-token-unix-socket branch from 280842e to c754a83 Compare September 17, 2026 22:05
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 17, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 18, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pod: mint_token sends the pod's .local_secret over TCP loopback — route it over the pod's unix socket

4 participants