Skip to content

feat(acp): add a dormant Codex ACP backend seam and name the harness-onboarding sequence - #7813

Merged
bolichen97 merged 2 commits into
mainfrom
feat/codex-seam-and-onboarding-doc
Sep 2, 2026
Merged

feat(acp): add a dormant Codex ACP backend seam and name the harness-onboarding sequence#7813
bolichen97 merged 2 commits into
mainfrom
feat/codex-seam-and-onboarding-doc

Conversation

@iamwhatever

Copy link
Copy Markdown
Collaborator

Problem / Motivation

Two gaps, one in the code and one in the docs.

Code. The core can serve a Codex session over ACP but has no vocabulary for it: no id, no provider label, no policy name, and no adapter resolution. Separately, the dashboard's agent-backend control derived its rows from a hard-coded [KIRO, CLAUDE, KAS] filtered by the live schema. Filtering a literal narrows correctly but can never widen, so an agent an edition registered through register_selectable_backend was selectable on the wire, valid to PATCH, present in the probe payload — and invisible in the only control that sets it.

Docs. harness-parity.md says what an added harness may not do to the Kiro path. Nothing said what it must do to land at all, so each onboarding rediscovered the order, and rediscovered it from KAS and Claude Code in retrospect. That is the wrong direction: the two harnesses that predate the invariants cannot show which stage a third one gets stuck at.

Why it matters

Without the vocabulary, every consumer that wants to name Codex has to spell a bare literal, which harness-parity H5 forbids and the added-line gate rejects. Without the policy-name mapping a governance author cannot deny the id before an edition registers it, which is the wrong order for a deny rule.

The frontend defect is the sharper one: it silently contradicted the module's own promise that "a build that ships another agent lights it up here with no frontend change". An edition could register a backend and have no way for an operator to pick it.

What changed (motivation → approach → change)

Vocabulary, in the leaf. ACP_BACKEND_CODEX, membership in ACP_BACKENDS_KNOWN, PROVIDER_LABEL_CODEX, and a POLICY_ID_BY_BACKEND entry. These live in acp_backends.py, which imports no ACP, so a consumer can name the constant without crossing the SDK boundary gate.

Capability decisions, all eight. Codex opts into the model and effort tuning channels and out of the other six. Three of the eight sets are new here — ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION, ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION, ACP_BACKENDS_KIRO_SLASH_COMMANDS — one per channel rather than one "tuning" set, because a harness can implement one and not another.

Spawn path. The codex CLI does not serve ACP (it reads acp as a prompt), so codex-acp is the transport, not an optimization: binary and npm-package constants, a hoisted-dependency marker so a broken adapter fails before the child is spawned rather than at ESM import time, an explicit CODEX_ACP_BIN override, and project-local node_modules resolution ahead of global/PATH. _vendored_claude_acp_roots becomes _vendored_acp_roots — harness-neutral root discovery shared by both resolvers, with each joining its own package path.

Handshake. PROTOCOL_VERSION_CODEX as its own literal, at the same number as Claude's. Per H10 that is not duplication: it makes a future divergence a one-line edit instead of a silent downgrade of whichever harness moved first.

Deliberately dormant. Codex is absent from BASELINE_SELECTABLE_BACKENDS, named in NOT_SHIPPED_SELECTABLE with its reason: backend_install.py has no _probe_codex, so a build offering the switch could not tell an operator what was missing when the session failed to start. One named stage, not a re-litigation.

Frontend. candidates becomes a union of server answers — schema enum ∪ probe payload ∪ saved current — with NAMED unioned in as a floor rather than a ceiling. As a ceiling it capped the panel at three ids forever; as a floor it only guarantees the core agents still have rows while both queries are in flight, since hiding a row on absent information is the same mistake as disabling one. Sorted (KIRO first, then by policy_id) so set-iteration order cannot reshuffle the control between renders.

New doc. docs/system-specs/modules/harness-onboarding.md names the seven stages a harness walks, written forward from this work so the gaps surface as gaps, and closes with the Codex seam as a worked example that stops at stage 5.

One doc fix. harness-parity.md's intro correctly describes Codex as a named exception in NOT_SHIPPED_SELECTABLE, but point 4 of "Adding or changing an invariant" still asserted ACP_BACKENDS_KNOWN and BASELINE_SELECTABLE_BACKENDS were equal and that any id outside the baseline fails the narrowing check. They are no longer equal, and the check now accepts a named exception. Both statements were in the same file.

Tests

  • test_harness_parity.py (+156): test_codex_is_known_but_not_shipped_selectable, test_codex_carries_its_own_provider_label, test_codex_spawn_keeps_its_own_branch, plus opt-in coverage for each new channel set (test_model_switch_channel_is_opt_in, test_effort_channel_is_opt_in, test_only_overlay_readers_are_written_to).
  • test_agent_backend_editable.py: NOT_SHIPPED_SELECTABLE turns the narrowing assertion into a named allowlist, so a plain baseline != known still fails — an id may sit outside the baseline only by being named there.
  • test_acp_client.py / test_acp_provider.py / test_acp_backend_kas.py / test_agent_sdk_backend_install.py: adapter resolution, protocol literal, and probe coverage follow the renamed helper.
  • AgentBackendTab.test.tsx (+43): pins that a server-registered id this frontend has no translated name for still renders under its policy_id, and that the NAMED floor keeps rows during loading.
  • .github/black-baseline.txt shrinks by one entry (test_acp_backend_kas.py is now black-clean).

734 backend tests pass across the seven affected files.

Manual verification

Gates run locally on this base: agent-sdk-boundary, harness-parity, docs-lint, flake8, isort, mypy, subprocess-encoding, sync-io-in-async, brand, changelog-history, focus-cue, testpaths-coverage, vendor-manifest — all pass. tsc -b clean.

Not run locally, disclosed rather than claimed: the frontend vitest suite. This host has Node 20.20.2; the repo requires Node ≥22 (.nvmrc pins 24) and vitest 4 passes --no-experimental-webstorage, which Node 20 rejects. Installing Node 24 failed local gpg verification and I did not bypass that check. CI is therefore the first run of AgentBackendTab.test.tsx.

Screenshots / video

Why no screenshot: on any build CI can produce, the rendered control is unchanged — candidates resolves to the same three ids, because Codex is absent from the schema enum and has no backend_install probe to appear in the probe payload. The change widens what an edition build can display and guarantees the core rows exist during the loading window; neither state is reachable on a public build, and the loading case is transient.

Related Issues

no linked issue: incremental step in the agent_sdk boundary work, tracked by the RFC at docs/request-for-change/rfc-crew-agent-sdk-boundary.md rather than an issue.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • 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

On the two commits: the first is authored by Vamil Gandhi and carries the Codex seam; the second is the docs work. They are deliberately not squashed, so the spawn-path work keeps its author rather than being absorbed under mine. Vamil's commit is taken from PR #6910.

@iamwhatever
iamwhatever requested a review from a team September 2, 2026 05:52
@iamwhatever
iamwhatever requested a review from a team as a code owner September 2, 2026 05:52
@iamwhatever
iamwhatever requested a review from dwu96 September 2, 2026 05:52
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of c21469892cf043bf784ddaa966e92f559fa0c7d8 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Invisible on public builds; on edition builds a registered backend gets a legible row (policy-id label, generic icon, honest status line) instead of vanishing.

[UX-REVIEWED] c214698

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of c21469892cf043bf784ddaa966e92f559fa0c7d8 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

The panel now widens selectability server-side, but per-backend disclosure stays a hard-coded frontend literal — capability generalizes, its compensating control doesn't.

Watch

  • AgentBackendTab's candidates change exists so "a build that ships another agent lights it up here with no frontend change" — but caveat() still returns text only for value === CLAUDE. AGENTS.md treats the panel caveat as the disclosure for an adapter harness whose sessions bypass Crew's MCP tools and approval path, and codex shares both gaps (_codex_session_mcp_servers defaults [], own credential/sandbox story). So the first register_selectable_backend activation renders a live, selectable chip with no disclosure line, and nothing forces the edition to add one. Serve the caveat (or a disclosure key) from the probe payload alongside policy_id, so disclosure travels with registration rather than lagging it.
  • The dormancy gate is convention: NOT_SHIPPED_SELECTABLE in a test names the exception, but nothing mechanically ties "may enter the baseline" to "backend_install has a probe" — the stated reason codex is dormant. A one-line test edit silently ships the switch the docs say cannot yet answer for a failed session. Acceptable as staged, worth a human eye.

Suggestions

  • When the install probe lands (the named Stage 5), fold the probe-existence requirement into test_baseline_ships_every_known_backend itself instead of the exception list, closing the convention gap above.

[DESIGN-REVIEWED] c214698

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of c21469892cf043bf784ddaa966e92f559fa0c7d8 — 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.

All counts run. Producing the review.

First-Principles-Verdict: CONCERNS

The dormant seam's only activator is out-of-repo — the RFC the description cites never names Codex — and is_codex_backend ships with zero consumers.

What this change ships

Intent: let the core name, spawn, and govern a Codex agent without offering it to anyone yet, and fix the dashboard panel that could never show a registered agent — an ADDITION carrying one FIX.

  1. Core can spell a "codex" agent: id, provider label, deniable policy name — justified (deny-before-register; H8 form)
  2. Codex sessions spawnable via the codex-acp adapter, with CODEX_ACP_BIN override — declared; unreachable on any public build
  3. Codex declares its own handshake literal — justified (H10 mandates the per-harness form)
  4. Model/effort/slash channels become opt-in membership sets, retiring claude-vs-rest branches — justified (H5/H6 mandate; all 3 sets have 2–5 src consumers)
  5. A harness in no effort channel is reported unsupported instead of guessed at — justified, no-op today
  6. GET /api/acp-backends now returns a codex row (unknown, unselectable) on every build — undeclared
  7. Backend panel lists any server-registered agent under its policy name — justified fix, cause-level (deletes the literal ceiling)
  8. Per-harness MCP hooks spliced only for their own harness — rides along, undeclared (inert in this tree)
  9. New harness-onboarding doc + harness-parity corrections — justified (spec-in-same-commit rule)
  10. AcpProvider.is_codex_backend property — zero consumers

Watch

  • The description grounds the seam in "tracked by the RFC at docs/request-for-change/rfc-crew-agent-sdk-boundary.md", but that file contains zero mentions of codex (grepped codex, case-insensitive), and register_selectable_backend has 0 callers under src/ (grepped; 8 hits, all definition/docstrings). The seam's zero option costs no public build anything — the author says so ("neither state is reachable on a public build") — so its whole warrant is the out-of-repo edition and PR feat(acp): add a dormant Codex ACP backend seam #6910. A human should confirm that consumer is real before this one-way vocabulary (a policy-nameable id) lands.
  • Item 6 is an API-observable change on every deployment; nothing in the description declares it.

Subtractions

  • Delete AcpProvider.is_codex_backend (src/kiro_crew/providers/acp.py:463) — grep is_codex_backend: 1 definition, 1 test, 0 consumers; sibling is_kas_backend earns its place with a real call site (providers/acp.py:775). Re-add it with its first consumer.
  • Shrink Stage 7 of docs/system-specs/modules/harness-onboarding.md: the per-file line counts "measured across the two in-flight live-harness branches" (+213, +112, +806) cite branches nothing in this repo can check and go stale the day either merges — keep the two rules (H13/H14), drop the measurements.

[FIRST-PRINCIPLES-REVIEWED] c214698

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c21469892cf043bf784ddaa966e92f559fa0c7d8 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] c214698

Verdict parsed from the review's SHA-scoped output markers for commit c21469892cf043bf784ddaa966e92f559fa0c7d8.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c21469892cf043bf784ddaa966e92f559fa0c7d8: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of c21469892cf043bf784ddaa966e92f559fa0c7d8 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c214698

False positive or not applicable? A repository writer can comment:
/ai-review override gpt c21469892cf043bf784ddaa966e92f559fa0c7d8: <one-sentence reason>

@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 2, 2026
vamgan and others added 2 commits September 2, 2026 06:28
Adds the Codex adapter as an adapted harness: known and driveable, but absent
from BASELINE_SELECTABLE_BACKENDS, so no build offers it until an edition
registers it with register_selectable_backend.

Vocabulary and identifier
- acp_backends.py: ACP_BACKEND_CODEX in ACP_BACKENDS_KNOWN and in
  POLICY_ID_BY_BACKEND, so a governance rule can name the id before an edition
  registers it. Absent from BASELINE_SELECTABLE_BACKENDS: the spawn path is
  complete, but backend_install.py has no probe for the adapter, so the switch
  would render with nothing to say about a session that failed to start.
  test_agent_backend_editable.py names the exception in NOT_SHIPPED_SELECTABLE
  rather than relaxing the baseline == known assertion.
- acp/types.py: PROVIDER_LABEL_CODEX, plus an explicit non-membership decision
  with its reason for every Group B capability set (H6/H8): one process per
  session, no _session/steer, a Node harness so Crew's sandbox is the only OS
  confinement it gets, and its own credentials store so a kiro logout says
  nothing about it.

Spawn path
- acp/client.py: _is_codex; PROTOCOL_VERSION_CODEX as its own literal (H10, so a
  future divergence is a one-line edit rather than a silent downgrade of
  whichever harness moved first); _resolve_codex_acp_bin mirroring the claude
  resolver's order and node-resolution rules; a _spawn branch; the
  _codex_session_mcp_servers override seam; per-harness log labels; and a
  session/load arm -- the adapter keeps its own session records, so gating
  file_ok on a kiro transcript would make an activated codex session start fresh
  every time instead of resuming.
- acp/client.py: each per-harness mcpServers hook is now spliced only for its
  own backend. Both defaults return [], so the previous shared splice was inert,
  but an edition overriding both hooks would hand a claude session codex's
  entries and vice versa -- and an entry whose transport the adapter does not
  advertise fails the whole session/new, not just that server.
- acp/client.py: _vendored_claude_acp_roots -> _vendored_acp_roots. The roots
  are plain node_modules directories and each resolver joins its own package
  path onto them, so both adapters share it; the claude name would have made the
  next reader think codex had its own copy.

Tuning channels stated as membership (H6)
Six sites in providers/acp.py read `if is_claude_backend: <adapter> else:
<kiro>`. That is correct with two harnesses and wrong with three: codex took the
kiro arm at every one of them. ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION and
ACP_BACKENDS_KIRO_SLASH_COMMANDS replace the negation.

- stream_command and change_effort routed codex into
  _kiro.dev/commands/execute, a kiro extension the adapter does not implement,
  so every slash command and every effort push would have returned -32601 and
  reset the session.
- _apply_effort_overlay and _apply_tool_search_overlay wrote the kiro workspace
  cli.json overlay for codex, which never reads it. The companion clear
  (_clear_cli_overlay_effort) was ALREADY membership-gated, so those writes left
  a stale effort level in the user's workspace that no later clear could reach.
- clear_effort took the kiro arm: overlay write, then a /effort push.
- The effort telemetry label was literally "claude" or "kiro", so a codex
  session logged backend=kiro. It now resolves through POLICY_ID_BY_BACKEND.
- change_effort now reports unsupported for a harness in NEITHER set instead of
  defaulting to a channel. _set_claude_effort -> _set_effort_config_option: the
  method is the channel, not the harness, and codex now arrives there too.

Tests
- test_harness_parity.py: codex is known but not shipped-selectable; codex
  carries its own provider label; the model-switch channel is opt-in; the effort
  channel is opt-in and the two channels are disjoint; the overlay is written
  only for its readers; each mcpServers seam is spliced only for its own
  harness; codex keeps its own spawn branch and protocol literal.
- test_acp_backend_kas.py: is_codex_backend in the mutual-exclusion pin, plus an
  ACP_BACKENDS_ACP_RUNTIME assertion. The is_acp_runtime_backend == not
  is_claude_backend equivalence is scoped to the three backends conversion
  covered rather than ACP_BACKENDS_KNOWN: that identity holds only while claude
  is the SOLE non-runtime harness, and codex is the first additional one, so
  widening it would require every future harness to be claude-shaped -- the
  negation reasoning H5/H6 exist to retire.
- test_agent_sdk_backend_install.py: the codex install row reads unknown and
  names nothing to install, which is the gap that keeps it out of the baseline.

Deliberately not changed: a failed adapter resolution stays cached for the
process lifetime. That is pre-existing, documented behaviour for the claude
resolver, and codex now matches it. Fixing it for codex alone would reintroduce
the asymmetry the rest of this change removes -- it should be both or neither,
in its own PR.

.github/black-baseline.txt loses one entry: test_acp_backend_kas.py became
black-clean, and the gate requires a graduated file to be pruned.
`harness-parity.md` says what an added harness may not do to the Kiro path.
Nothing said what it must do to land at all, so each onboarding rediscovered
the order — and rediscovered it from KAS and Claude Code in retrospect, which
is the wrong direction: the two harnesses that predate the invariants cannot
show which stage a third one gets stuck at.

Derived from the Codex seam instead, which makes the gap visible as a gap. Two
stages here did not exist as stages until a third harness needed them. Stage 2
had five capability sets and no tuning channels at all; the Codex work took it
to eight, one set per channel because a harness can implement one and not
another. Stage 5 (the install probe) was invisible while every known id
happened to be selectable, and it is the reason Codex lands dormant — one
named stage, not a re-litigation.

Also fixes a contradiction the Codex seam left in `harness-parity.md`: the
intro correctly describes Codex as a named exception carried in
`NOT_SHIPPED_SELECTABLE`, while point 4 of "Adding or changing an invariant"
still asserted `ACP_BACKENDS_KNOWN` and `BASELINE_SELECTABLE_BACKENDS` were
equal and that any id outside the baseline fails the narrowing check. They are
no longer equal, and the check now accepts a named exception.
@iamwhatever
iamwhatever force-pushed the feat/codex-seam-and-onboarding-doc branch from ff0ec5e to c214698 Compare September 2, 2026 06:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 2, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) September 2, 2026 08:53
@bolichen97
bolichen97 merged commit f51e659 into main Sep 2, 2026
69 checks passed
@bolichen97
bolichen97 deleted the feat/codex-seam-and-onboarding-doc branch September 2, 2026 15:56
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 2, 2026
iamwhatever pushed a commit that referenced this pull request Sep 4, 2026
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.

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.
iamwhatever pushed a commit that referenced this pull request Sep 4, 2026
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.
iamwhatever pushed a commit that referenced this pull request Sep 4, 2026
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.
iamwhatever pushed a commit that referenced this pull request Sep 4, 2026
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.
iamwhatever added a commit that referenced this pull request Sep 4, 2026
)

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.

Co-authored-by: zejiangg <zejiangg@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants