feat(acp): wire Codex in behind an enforced tool-permission route - #7963
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, fail-closed design throughout — but on hosts with no sandbox backend the picker offers a harness that can never start, failing the PR's own "a picker entry is a promise" bar. Watch
Suggestions
[DESIGN-REVIEWED] bc3dcc0 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts are verified. The change is well-grounded overall, but it ships a truth-reporting API layer ( First-Principles-Verdict: CONCERNS The enforcement half is derived and consumed; the verdict-reporting half ( What this change shipsIntent: let an operator actually choose Codex as a harness without their security controls silently going out of circuit — an ADDITION, carrying one independent FIX (the Claude/Codex OAuth-token floor gap).
Capped at 10; docs/spec updates and the black-baseline prune of Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] bc3dcc0 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the exception hierarchy ( No blocking findings. FINDING — src/kiro_crew/acp/client.py:3609 — the broad [OPUS-REVIEWED] bc3dcc0 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @iamwhatever overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
82f7ec4 to
34f6094
Compare
34f6094 to
b03868a
Compare
Legitimate, and the trace is exactly right:
The guard tested a tier when the thing it depends on is whether the mask is applied, and those are not the same question. Rather than add the second branch to the gate — which would leave the same drift open for a third — the branch knowledge now lives beside the branches, in if credential_mask_applies(mode):
return
raise ToolGateUnroutable(...)I enumerated every Two tests pin both halves: |
The defect is legitimate and converges with the Opus lane's finding on the same commit:
Fixed at the guard rather than by the proposed remedy. On the proposed fix — "Remove Codex from the baseline until every unwrapped sandbox path refuses it" — the second clause is now satisfied, so the precondition for removal no longer holds. Removing Codex from Covered by |
b03868a to
caca0a1
Compare
caca0a1 to
2166c93
Compare
Legitimate, and self-inflicted by the previous round's guard:
Fixed by offloading, and by taking the second half of the suggested fix too: the credential-target resolution also touches the filesystem (home plus every env-override root), so both steps now run in one worker thread rather than two hops: adapter_hidden_dirs = await asyncio.to_thread(
_sandbox_preflight, self.backend, self._sandbox_mode
)
Verified: |
|
/ai-review override gpt d6d6221d8b74ba0f4b7ba4b0e42d3e39fca4c1a9: Same accepted residual re-scoped to the current head — an ACP adapter reading its own on-disk OAuth token is inherent to running Claude Code or Codex at all, is not closable in this repo because codex-acp offers no off-disk credential channel, and this change strictly reduces total credential exposure by putting both adapter tokens on the read-gate floor for the first time and masking every other floor credential from the Codex child. |
|
AI-review override not recorded: |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
d6d6221 to
af985b8
Compare
|
/ai-review override gpt af985b8: Two residuals accepted by the maintainer and now documented in the PR — an ACP adapter reading its own on-disk OAuth token is inherent to running Claude Code or Codex at all and is closable only upstream, and the Linux mask being point-in-time is a property of bind-mount masking shared by every extra_hidden_dirs consumer and by the standard tier itself, with no fix available at that layer; the third finding on this head, the contradictory Claude-settings claim, is fixed rather than accepted. |
The mechanism is real and I verified it rather than arguing from the diff. Both launcher mask loops are guarded on existence — Scope worth stating precisely. macOS is not affected: after the previous round its rules are The maintainer has accepted this as a disclosed residual and a It is a property of the Linux bind-mount masking shared by every consumer of There is no fix at this layer. A non-existent path cannot be a bind mountpoint, and manufacturing one by creating the leaf would leave a stray empty credential file on the operator's host after the session — an empty I am not waving it away on the strength of "pre-existing", though: the mask is the only compensating control for an enforced adapter, so absence costs more here than it does for a harness whose tool calls reach the gate anyway. That is why it is now written into the PR's Known-gap section with the mechanism named, rather than left implicit — and why the general fix is worth tracking separately rather than dropping. |
This is a repeat of an already-adjudicated finding, not a new defect, and the evidence has not moved. Re-checked against the current base rather than restated: on It also cannot move earlier. Those existing Codex arms run while the And the one alternative that removes the conditional is strictly worse against this same anchor: self-guarding inside Same-span stall, recorded as the rule asks: this span has now blocked rounds 5 (H13, unconditional Not deferred and not waived: if there is a placement for a post- |
Correct, and it was my error. The contradiction was in three places, not one, and the third is the reason this was worth more than a comment edit:
All three now describe the actual limitation, which is a read-back gap rather than a missing writer: the method does seed The same false sentence was in the PR description's "Scope: Claude is declared but not enforced" section and is corrected there too. Worth noting it strengthens rather than weakens the surrounding argument: an unenforced harness is unenforced because its precondition is unverifiable, which is a sharper claim than "nobody writes the file". It does not touch the mask asymmetry — Verified: 16 local gates green including docs-lint, mypy clean across 1280 files, 2361 tests pass across the touched suites, single commit on the current |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Codex could be spelled by the core but not chosen. The vocabulary, the spawn path and the adapter resolution landed in #7813; what was missing was everything that decides whether offering the switch is honest. Nothing established how a harness is made to ask before it runs a tool, and backend_install.py had no probe that could explain a failed session. acp_tool_gate resolves one routing verdict per harness, so the refusal message, a doctor row and any later surface cannot disagree, and it REFUSES a session whose tool calls would bypass HookManager.on_tool_call. For Codex the client verifies that session/new advertised mode=read-only and applies it after session/new|load, before any prompt can run. Not advertised gives INDETERMINATE, an observed write rejection gives BYPASSED, and both refuse identically: a guarantee that lapses when evidence is missing is not a guarantee. There is no local opt-out, because a config bool could undercut a governance ceiling set above the operator. It also puts both ACP adapter OAuth token stores -- ~/.codex/auth.json and ~/.claude/.credentials.json -- on the sensitive-home read-gate floor. Neither was classified before, so the agent's own fs_read could lift either one. Kiro Crew never reads them and only ever checks that one exists so it can name the right sign-in command, so nothing legitimate loses access, and only the token leaf is classified -- the adapter's readable siblings stay readable. The credential mask handed to an enforced adapter's child is DERIVED from security.sensitive_home_dirs() minus that harness's own leaf, re-anchored under every override root and under both home spellings, so a leaf added to the floor is covered with no edit in the ACP layer. The macOS profile emits both a subpath and a literal deny for each entry, because most of that list is plain files and a subpath rule over a non-directory was asserted in three comments here and contradicted in a fourth, with no test exercising sandbox-exec either way. Selecting Codex with agent.sandbox="off" now refuses to start. wrap_argv returns from its off branch before it applies extra_hidden_dirs, so the mask -- Codex's only compensating control, since ACP v1 cannot prompt on a passive read -- silently evaporated. The refusal keys on the EFFECTIVE tier so a governed min_level floor does not false-refuse, and a host with no sandbox backend fails closed without consulting any mutable policy value, since both the opt-in and the floor can move between the preflight and the spawn. A gate refusal is non-retryable end to end. AcpToolGateUnroutable documents itself so but subclasses AcpError, so ensure_ready's transport ladder used to respawn the adapter once before reaching the identical refusal; it now has its own handler ahead of that ladder. And _sandbox_preflight translates the leaf module's plain ToolGateUnroutable into it at the boundary, the way the session-routing path already did -- raised raw it was neither an AcpError nor the type that handler names, so a sandbox-floor refusal escaped ensure_ready uncaught and skipped the cleanup every other refusal path runs. Known gap, documented rather than closed: ACP v1 offers no way to require a prompt for a passive read, so an adapter can still read its own token. That is inherent to running these harnesses at all -- kiro-cli is handed its own model credential in an environment nothing gates, and Claude Code already ships on this baseline with no credential mask at all -- and closing it needs upstream support rather than a change here.
af985b8 to
bc3dcc0
Compare
|
/ai-review override gpt bc3dcc0: Two residuals accepted by the maintainer and documented in the PR — an ACP adapter reading its own on-disk OAuth token is inherent to running Claude Code or Codex at all and is closable only upstream, and the Linux mask being point-in-time is a property of bind-mount masking shared by every extra_hidden_dirs consumer and by the standard tier itself, with no fix available at that layer; re-scoped to the current head after a rebase onto main. |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
Codex could be spelled by the core but not chosen. The vocabulary, the spawn path, the adapter resolution and the per-harness protocol literal all landed in #7813; what was missing was everything that decides whether offering the switch is honest.
Two things were missing, and they were separate reasons:
backend_install.pyhad no probe for the adapter. A build offering the switch could not tell an operator what was absent when a session failed to start — the install row could only readunknown.HookManager.on_tool_call, reached only from the permission-request branch of the dispatch parser. A harness that does not sendsession/request_permissionper tool call is a harness where none of those controls execute. codex-acp's defaultagentmode permits writes inside the workspace without asking, so selecting it would have put the operator's narrowing silently out of circuit.Separately, and independent of Codex:
~/.claude/.credentials.jsonwas not on the sensitive-path floor while Claude Code is inBASELINE_SELECTABLE_BACKENDS. That was a live agent-readable OAuth token, not a hypothetical.Why it matters
A picker entry is a promise. Offering a harness whose tool calls skip the gate means an operator who narrowed their denied-command rules, or who relies on the sensitive-path block, gets neither for the calls that harness self-approves — with nothing in the UI or the logs saying so. And an install row reading
unknownhands a failed session to the operator with nothing to act on.The Claude token gap needed no Codex work to matter: any agent
fs_readcould lift it and impersonate the operator against that vendor.What changed (motivation → approach → change)
The credential floor (independent of Codex, and it stands on its own merits).
~/.codex/auth.jsonand~/.claude/.credentials.jsongo on the sensitive-home floor. Kiro Crew never reads either — it only checks that one EXISTS so it can name the right sign-in command — so nothing legitimate loses access. Only the token leaf is classified, never the adapter directory: the siblings (codexconfig.toml, claudesettings*.json) stay readable because routing diagnosis reads them and they carry no credential.Both adapters honour a home override, so the resolved-root helper becomes a
_ResolvedRootsNamedTuple carrying the resolved home, the logical (unresolved) home, and the override roots each adapter honours —KIROCREW_HOME,CLAUDE_CONFIG_DIR,CLAUDE_HOME— and the builder re-anchors each leaf under them. That tuple is both the anchor set and the TTL cache key on purpose: a root the builder anchors on but the key omitted would serve targets computed for the previous value, the fail-open shape the resolved-home key already exists to prevent. Two details are load-bearing and easy to "clean up" wrongly:/home/u→/local/home/uresolves one and reports the other, so anchoring only the resolved form leaves the logical path undenied..strip()._resolved_env_rootnow matches_valid_override_home(). Stripping anchored the floor on<root>while the process actually ran from"<root> ", denying nothing.The route.
acp_tool_gateresolves one verdict per harness, so the refusal message, a doctor row and any later dashboard surface cannot disagree. For Codex the client verifiessession/newadvertisedmode=read-onlyand applies it after session/new|load, before any prompt can run. Not advertised → INDETERMINATE (Kiro Crew cannot tell); write rejected → BYPASSED (an observed failure). Both refuse identically, because a guarantee that lapses when evidence is missing is not a guarantee.There is no local opt-out. An earlier revision carried an
agent.acp_backend_allow_ungated_toolsbool that started the session anyway with a warning. It is gone, and its absence is now asserted by a test: a local config bool can undercut a governance ceiling set above the operator, which is exactly the control the bypass would be waiving. The refusal is unconditional and carries the three unenforced controls by name plus a remedy.The verdict and the apply ship together deliberately:
routing_verdictreturns ROUTED on a promise, because the option lives on a session that does not exist yet. Ported without its caller, the harness would report routed while running its own default mode — the one silent-bypass hole in this design.The credential homes. The standard sandbox tier deliberately leaves the AWS, SSH and kube home directories readable because kiro-cli needs its AWS config file for the
credential_processpath; hiding them fleet-wide would degrade the first-class harness. An enforced adapter authenticates through its own token store and has no claim on any of them, so they are denied to its child through the existingextra_hidden_dirsextension point — additive, scoped to one harness, and it does not override the operator's chosen sandbox mode.The mask is derived, not enumerated:
security.sandbox_credential_targets()reuses the read gate's own anchor rules —sensitive_home_dirs()minus the adapter's own leaf, re-anchored under every override root — so a leaf added to the floor is covered with no edit in the ACP layer. It is empty for every harness this core does not enforce, so their spawn arguments are unchanged. On macOS the profile emits both asubpathand aliteraldeny for each entry: most of that list is plain files rather than directories, and whether asubpathrule alone covers a non-directory was asserted in three comments in this tree and contradicted in a fourth, with no test exercisingsandbox-execeither way — so the mask no longer depends on which reading is right.New behaviour change: selecting Codex with
agent.sandbox="off"now refuses to start.wrap_argvreturns from itsmode == "off"branch before it appliesextra_hidden_dirs, so the mask — Codex's only compensating control, since ACP v1 cannot prompt on a passive read — silently evaporated.acp_tool_gate.enforce_sandbox_floor()fails that combination closed at spawn time. It keys on the effective tier fromsandbox.effective_sandbox_mode(), so a governedmin_levelfloor that already raises the tier does not produce a false refusal, and the one branch where the mask legitimately does not apply (nested-sandbox passthrough) is enumerated insandbox.credential_mask_applies(), beside the branch it describes rather than re-derived in the gate. A host with no sandbox backend fails closed unconditionally, reading neither the unsandboxed-exec opt-in nor the governance floor: both are mutable config read at the preflight and acted on at the spawn, so either one moving in that window would drop the refusal that made an allowance safe to report.Adapter work stays behind the adapter's own seam. The refuse-then-mask preflight runs inside the pre-existing
elif self._is_codex:arm ofAcpClient._spawn, not behind a new gate on the shared construction path: harness-parity H13 asks whether the Kiro path changed, and both a new conditional and a new awaited step there count as the change. Kiro reacheswrap_argv_asyncwith neither. The cost of that placement is that a future enforced harness needs its own call, so a ratchet test pins one preflight call site per enforced harness — forgetting it fails CI instead of shipping an unmasked adapter.A gate refusal no longer costs a wasted spawn.
AcpToolGateUnroutabledocuments itself Non-retryable — the refusal is a configuration fact, so a respawn re-reads the same answer — but it subclassesAcpError, soensure_ready's transport retry ladder caught it, tore the child down, respawned, hit the identical refusal and only then raised. It now has its own handler ahead of the generic one, which also keeps the reconnect budget for the transport faults it was meant for._sandbox_preflightalso translates the leaf module's plainToolGateUnroutableinto that type at the boundary, the way the session-routing path already did: raised raw it was neither anAcpErrornor the class the dedicated handler names, so a sandbox-floor refusal escapedensure_readyuncaught and skipped the cleanup every other refusal path runs.The probe, and the flip.
_probe_codexreportsmissingwith the component and the command that installs it. One component, not claude's two: codex-acp ships its own compatible Codex binary. Only with the route and the probe together does the baseline entry stop being a switch ahead of its answer, soNOT_SHIPPED_SELECTABLEis empty again — the state its own docstring calls healthy.Known gap, stated rather than closed
ACP v1 offers no way to make an adapter ask for a passive READ.
mode=read-onlycovers commands and changes; reads emit nosession/request_permission, so the sensitive-path block does not see reads this harness performs. That does not close, and this PR does not claim it does.What made it dangerous was the combination with readable credential homes, and that combination is what the sandbox change removes. The SSH directory is included deliberately and it has a cost: git-over-SSH inside a Codex session stops working, because the private key is no longer readable. Leaving private keys readable would not close what this exists to close, and a harness landing new has no established workflow to break. An operator who needs it back has the sandbox mode to lower, deliberately — and lowering it all the way to
offis now a refusal rather than a silent downgrade.Second known gap, accepted with a recorded override: on Linux the mask is point-in-time. Both launcher mask loops are guarded on existence (
if os.path.isdir(target)/if os.path.isfile(target)), because a bind mount needs a mountpoint. A floor leaf that does not exist at spawn therefore gets no mount at all, so one created during the session —~/.git-credentialsfrom a credential helper,~/.npmrcfrom a registry login — is readable by the child for the rest of that session. macOS is not affected: its rules are path patterns and apply whether or not the file exists.This is a property of the Linux bind-mount mechanism shared by every consumer of
extra_hidden_dirsand by the standard tier's own hidden dirs — an absent~/.awscreated mid-session is equally unmasked for kiro-cli — so it is not introduced here. It is disclosed rather than waved away, because the mask is the only compensating control for an enforced adapter, which makes absence cost more here than elsewhere. Closing it means changing the Linux masking mechanism itself (overlaying the parent, or a path-based rather than mountpoint-based deny) for all consumers; creating the leaf to manufacture a mountpoint is not an option, since it would leave a stray empty credential file on the host that could itself break the operator's auth.Scope: Claude is declared but not enforced
Claude is
SEEDED_SETTINGSand is not enforced here, and the reason is a read-back gap rather than a missing writer._write_claude_local_settings(acp/client.py, called from the claude arm of_spawn) does seedpermissions.defaultModeinto<work_dir>/.claude/settings.local.json— but the write is conditional on ownership: it touches only a file Crew created this session whose bytes are still the ones Crew wrote, and otherwise leaves the path alone. Nothing then reads back whether the adapter honoured the mode, so abypassPermissionsalready sitting in a user's ownsettings.local.jsonor~/.claudeis neither detected nor stripped. That is what cannot be established, so the verdict stays INDETERMINATE.routing_verdictreports that honestly as INDETERMINATE rather than upgrading it to a ROUTED this core cannot earn. What is scoped is whether a verdict refuses, not whether it is told truthfully — which keeps a shipped harness working while leaving its gap visible. Widening the scope means implementing a mechanism, not editing an allowlist. Claude's unrouted state on public builds is a real finding this PR documents but does not fix.Tests
test_acp_backend_credentials.py(13, new): both leaves on the floor, both default locations blocked, siblings still readable, each override root anchored one variable at a time, and the override roots proven to be cache-key fields. Revert-verified: 10 fail with the change removed.test_acp_tool_gate.py(47, new): split along the axis that matters — what a verdict SAYS versus whether it REFUSES. Pins that Claude is INDETERMINATE and not refused, that INDETERMINATE refuses alongside BYPASSED for an enforced harness, that the refusal names all three controls and a remedy, that no local opt-out exists, and five malformed-advertisement shapes each failing closed with their own reason. Also covers the sandbox floor (offrefuses an enforced adapter; a governed floor still starts; nested passthrough refuses), the derived mask (whole read-gate floor covered, adapter's own token still readable, override-relocated credentials re-anchored, paths absolute), and the newtest_every_enforced_harness_reaches_the_spawn_preflightratchet.test_harness_parity.py(34):test_codex_is_selectable_and_answerableandtest_codex_tool_calls_are_gated_before_it_is_offeredassert selectability, the probe and the route TOGETHER — either alone is the failure mode, so a change dropping one fails here.2362 tests pass across the gate, credentials, parity, client, client-coverage, security and sandbox-profile suites.
test_tool_gate_refusal_does_not_retry_the_spawnpins the single spawn,test_sandbox_preflight_translates_the_gate_refusalpins the boundary translation, andtest_extra_hidden_file_leaf_also_gets_a_literal_denypins the file-shaped deny rule; all three are revert-verified.Manual verification
16 diff-scoped gates pass: harness-parity, sync-io-in-async, subprocess-encoding, agent-sdk-boundary, brand-name, feature-map, changelog-history, focus-cue, loop-bound-locks, testpaths-coverage, builtin-skill-scope, vendor-manifest, lockdown-before-publish, black, docs-lint and scrub-lint. mypy is clean across 1282 source files.
Not run locally, disclosed rather than claimed: no live Codex session. This host has no
codex-acpinstalled — which the new probe correctly reports asmissing. The refusal, apply and mask paths are covered by unit tests against advertised/unadvertised config payloads and synthetic override roots, but the end-to-end "a real codex-acp session starts and asks" path is unexercised here.config-baseline.jsonwas regenerated by the repo's ownscripts/generate_config_baseline.py.Screenshots / video
Why no screenshot: no rendered surface changes. The new config field is schema-driven and appears in Settings from the generated baseline; the dashboard's backend switch already renders from the live registry (union of server answers) rather than a hard-coded list, so Codex appearing there is data, not markup.
Related Issues
no linked issue: continues the
agent_sdkboundary work tracked bydocs/request-for-change/rfc-crew-agent-sdk-boundary.md, and builds on the Codex seam merged in #7813. Reference implementation for the routing model is #6307, which reached the same verdict on Codex's read gap.Pattern harvest
Rule candidate: review-prompt — a picker entry and an armed gate are separate facts, and shipping one without the other is the defect. Both times a harness reached
BASELINE_SELECTABLE_BACKENDSin this repo, something that justified admitting it was missing: Claude's OAuth token was off the sensitive-path floor, and its routing was never established on public builds. The generalizable check is that adding an id to a selectable set requires naming, in the same change, what makes the offer honest — an install probe that can explain a failure, and a routing verdict that is earned rather than assumed.Second candidate: harness-parity — a compensating control that a lower sandbox tier silently discards is not a control. The mask was correct and still evaporated at
mode == "off", because the tier that disables the sandbox returns before the extension point that applies it. Any control delivered through the sandbox needs a spawn-time check that the tier in force actually delivers it.Checklist