Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/black-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,6 @@ test/test_safety_override.py
test/test_sage_backend_routes_coverage.py
test/test_sandbox_argv.py
test/test_sandbox_backend_cache.py
test/test_sandbox_cc_mode.py
test/test_sandbox_default_fix.py
test/test_sandbox_first_party_exec.py
test/test_sandbox_hardlink_scan.py
Expand Down
6 changes: 4 additions & 2 deletions docs/system-specs/modules/harness-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,10 @@ The Codex onboarding is a clean instance of stopping at Stage 6:
| 2 capability sets | Decided for all eight: in the model and effort channels, out of the other six. All three channel sets were *created* by this work, which is why the count went from five to eight. |
| 3 spawn path | Done — adapter, npm package, dep marker, env override, project-local resolution. |
| 4 handshake | Done — `PROTOCOL_VERSION_CODEX`, its own literal at the same number as Claude's. |
| 5 install probe | **Absent.** No `_probe_codex` in `backend_install.py`. |
| 6 selectability | Dormant by consequence, named in `NOT_SHIPPED_SELECTABLE` with Stage 5 as the reason. |
| 5 install probe | Done — `_probe_codex` names `codex-acp` and the command that installs it. One component, not two: the adapter ships its own Codex binary. |
| 6 selectability | Selectable. `NOT_SHIPPED_SELECTABLE` is empty again, which is the healthy state. |
| routing | Done — `SESSION_CONFIG`, verified and applied as `mode=read-only` after session/new and before the first prompt, refusing otherwise. |
| residual | ACP v1 cannot require a prompt for a passive READ, so the sensitive-path block does not see this harness's reads. Mitigated at the OS boundary instead: its child cannot read the credential homes the standard tier leaves open. |
| 7 live spill | Not reached. |

The lesson worth carrying: the seam is dormant for exactly one reason, that
Expand Down
12 changes: 6 additions & 6 deletions docs/system-specs/modules/harness-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ core can spell is an id an operator can choose unless something states the
exception — pinned by
`test_agent_backend_editable.py::test_baseline_ships_every_known_backend`, which
guards against an undocumented NARROWING rather than a widening.
`ACP_BACKEND_CODEX` is the one exception and it is named in that test: the spawn
path is complete, but `backend_install.py` has no probe for the adapter, so a
build offering the switch could not tell an operator what was missing when the
session failed to start. It becomes selectable through
`register_selectable_backend`, or through the baseline once that probe lands.
There is no exception today: `NOT_SHIPPED_SELECTABLE` is empty, which is the
healthy state. `ACP_BACKEND_CODEX` was the last member and left it once both
halves landed — `backend_install.py` gained its probe, so the install row names
the missing component and its command instead of reading `unknown`, and
`acp_tool_gate` established that its tool calls reach the PreToolUse gate.

Read the invariants below against that tree: three harnesses can serve a real
session today, so a site that spells "kiro" by exclusion is already wrong on two
Expand Down Expand Up @@ -90,7 +90,7 @@ already reads TRUE for KAS on a plain public build.
| H5 | Harness identity is a positive comparison against a named constant, or membership in a named set. `not is_claude_backend`, `!= ACP_BACKEND_KAS`, and `== "kas"` (bare literal) are all forbidden; `is_kiro_backend` and `backend in ACP_BACKENDS_<CAP>` are the forms. Enforced on the lines a change ADDS, not whole-tree — see the gate doc for why. | `scripts/check_harness_parity.py` (six rules, self-tested), `test_harness_parity.py::test_added_line_gate_self_test_passes`, `::test_added_line_gate_flags_a_planted_negative_test` | every module reading `AcpClient.backend` / `AcpProvider.is_*_backend` |
| H6 | A capability is granted by opt-in membership, never by negation. `is_session_sharing_eligible` reads `ACP_BACKENDS_SESSION_SHARING` and `supports_steer` reads `ACP_BACKENDS_STEER`, so a harness that has not demonstrated the capability does not inherit it from a set it was never added to. Every *tuning channel* follows the same rule, one set per channel because a harness can implement one and not another: `ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION` (model switch), `ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION` (effort push), and `ACP_BACKENDS_KIRO_SLASH_COMMANDS` — membership in the last also decides who is sent `_kiro.dev/commands/execute` and who gets the workspace `cli.json` overlay written for them. A harness in none of these must not inherit a channel that answers `-32601`, nor collect an overlay it never reads and the membership-gated clear can never remove. `ACP_BACKENDS_MCP_CONFIG_HOT_RELOAD` follows the same rule for a *skip*: membership is what lets the dashboard's MCP sync leave running sessions alone after a config write, because the harness reconciles the agent file itself (kiro-cli, from the release its reconcile was verified on; `mcp_hot_reload.py` holds every live process to that floor, read from its own `initialize` handshake). A harness that never demonstrated the reconcile would otherwise have its users' freshly installed servers stay unmounted with nothing red to say why. | `test_harness_parity.py::test_session_sharing_is_opt_in`, `::test_steer_is_opt_in`, `::test_model_switch_channel_is_opt_in`, `::test_effort_channel_is_opt_in`, `::test_only_overlay_readers_are_written_to`, `::test_mcp_config_hot_reload_is_opt_in` | `providers/acp.py` (`AcpProvider.is_session_sharing_eligible`, `change_effort`, `clear_effort`, `_apply_effort_overlay`, `_apply_tool_search_overlay`, `stream_command`), `acp/client.py` (`AcpClient.supports_steer`), `acp_backends.py`, `mcp_hot_reload.py` (`mcp_hot_reload_supported`) |
| H7 | `is_kiro_cli` is a positive Kiro test at every call site. It drives internal-sandbox delegation: macOS skips Kiro Crew's seatbelt because Kiro's sandbox cannot nest inside it, and Windows permits the official Kiro backend to run despite having no Kiro Crew OS wrapper. Passed for a harness with no internal sandbox, it hands isolation to a layer that never starts; this is the only Group B row that is also a security invariant. **Windows requires `is_kiro_cli is True` exactly** — `None` and `_spawns_kiro_cli` basename inference can never grant the backend-less-host exception. On macOS a site may grant membership explicitly or pass `None` to defer to the positive basename test. | `test_harness_parity.py::test_is_kiro_cli_is_positive`, `test_sandbox_argv.py::TestKiroInternalSandboxExclusion` | `acp/runtime.py` (`AcpRuntime.spawn`), `acp/client.py` (`AcpClient.ensure_ready`), `sandbox.py` (`wrap_argv`, `_spawns_kiro_cli`) |
| H8 | New harness identifiers live in `acp_backends.py` — a LEAF module, so every consumer can name the constants rather than copy them — and are added to `ACP_BACKENDS_KNOWN`; every capability set is a subset of it; and `AcpProvider.__init__` rejects anything outside it. `ACP_BACKEND_KIRO` is the empty string, so a value that falls through every identity check spawns `kiro-cli` under a foreign label. `acp/types.py` re-exports the vocabulary and remains the import site for existing callers. | `test_harness_parity.py::test_capability_sets_are_subsets_of_known_backends`, `::test_unknown_backend_rejected_at_construction`, `::test_codex_is_known_but_not_shipped_selectable` | `acp_backends.py` (`ACP_BACKENDS_KNOWN`), `providers/acp.py` (`AcpProvider.__init__`), `scripts/check_harness_parity.py` (`VOCABULARY_PATH`) |
| H8 | New harness identifiers live in `acp_backends.py` — a LEAF module, so every consumer can name the constants rather than copy them — and are added to `ACP_BACKENDS_KNOWN`; every capability set is a subset of it; and `AcpProvider.__init__` rejects anything outside it. `ACP_BACKEND_KIRO` is the empty string, so a value that falls through every identity check spawns `kiro-cli` under a foreign label. `acp/types.py` re-exports the vocabulary and remains the import site for existing callers. | `test_harness_parity.py::test_capability_sets_are_subsets_of_known_backends`, `::test_unknown_backend_rejected_at_construction`, `::test_codex_is_selectable_and_answerable` | `acp_backends.py` (`ACP_BACKENDS_KNOWN`), `providers/acp.py` (`AcpProvider.__init__`), `scripts/check_harness_parity.py` (`VOCABULARY_PATH`) |

## Group C: the Kiro path keeps its own machinery

Expand Down
155 changes: 154 additions & 1 deletion src/kiro_crew/acp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
TypeVar,
)

from kiro_crew import agent_scratch, model_registry, platform_compat
from kiro_crew import acp_tool_gate, agent_scratch, model_registry, platform_compat
from kiro_crew.acp._dispatch import (
_kiro_mcp_server_name,
_kiro_tool_name,
Expand Down Expand Up @@ -1345,6 +1345,20 @@ class AcpAuthRequired(AcpError): # noqa: N818
"""


class AcpToolGateUnroutable(AcpError): # noqa: N818
"""The harness's tool calls would not reach Kiro Crew's PreToolUse gate.

Non-retryable, and a DISTINCT type from the transport errors around it: the
condition is a configuration fact, so a respawn re-reads the same answer and
refuses again while consuming a reconnect budget meant for transport faults.

Wraps :class:`kiro_crew.acp_tool_gate.ToolGateUnroutable`, which cannot
subclass ``AcpError`` itself -- it lives in a LEAF module that must not import
this one (import cycle, and a forbidden-root edge for the SDK boundary gate).
Branchless callers keep degrading through their generic ``AcpError`` handling.
"""


class AcpModelUnavailable(AcpError): # noqa: N818
"""An explicitly requested model is not available to this account.

Expand Down Expand Up @@ -2519,6 +2533,34 @@ def _select_tool_title(
return None


def _sandbox_preflight(backend: str, mode: str) -> tuple[str, ...]:
"""Refuse an unmasked enforced adapter, then resolve its credential mask.

One function so the caller pays ONE ``asyncio.to_thread`` hop for both steps:
``enforce_sandbox_floor`` probes for a sandbox backend and
``adapter_hidden_credential_dirs`` resolves the home and every env-override root,
and both are blocking filesystem work that must not run on the event loop.

Raises :class:`AcpToolGateUnroutable` when this session would spawn the adapter
with its mask dropped; returns the mask otherwise (empty for a harness this core
does not enforce, so their spawn arguments stay byte-identical).
"""
try:
acp_tool_gate.enforce_sandbox_floor(backend, mode)
return acp_tool_gate.adapter_hidden_credential_dirs(backend)
except acp_tool_gate.ToolGateUnroutable as exc:
# Translate at the boundary, exactly as the session-routing path does.
# ``acp_tool_gate`` is a LEAF that cannot import this module, so its
# ToolGateUnroutable is a plain ``Exception``: it is neither an
# ``AcpError`` (so the transport ladder in ``ensure_ready`` cannot see
# it) nor the ``AcpToolGateUnroutable`` the dedicated non-retrying
# handler names (an unrelated class). Raised raw, a sandbox-floor
# refusal therefore escaped ``ensure_ready`` uncaught and skipped the
# cleanup every other refusal path runs. ``from None`` because the
# wrapper carries the whole actionable message already.
raise AcpToolGateUnroutable(str(exc)) from None


class AcpClient:
"""JSON-RPC 2.0 client over stdio with kiro-cli acp."""

Expand Down Expand Up @@ -3532,6 +3574,63 @@ async def set_config_option(self, config_id: str, value: str) -> None:

# ── Dynamic Config from ACP ──

async def _apply_session_permission_routing(self) -> None:
"""Make a SESSION_CONFIG harness actually ask, or refuse to run it.

Called ONLY for a ``SESSION_CONFIG`` harness -- the caller tests that, so
the Kiro path never reaches this method (harness-parity H13).

Two outcomes, and each is a different verdict on purpose:

* the option was not advertised -> INDETERMINATE, because Kiro Crew cannot
tell what the adapter will do, and "cannot tell" must not read as armed;
* the write was rejected -> BYPASSED, an observed failure rather than an
unknown.

Only the enforced mechanisms refuse; ``enforce_runtime_routing`` owns that
decision, so the scope lives in one place instead of being re-derived here.
"""
backend = self.backend
option_id, value = acp_tool_gate.permission_config_for(backend)
issue = acp_tool_gate.session_config_issue(backend, self._acp_config_options)
if issue:
# Not advertised: INDETERMINATE, never BYPASSED. The adapter may well
# ask anyway; Kiro Crew simply has no evidence, and the enforcement
# treats the two identically while the message stays honest.
try:
acp_tool_gate.enforce_runtime_routing(
backend,
issue,
verdict=acp_tool_gate.Verdict.INDETERMINATE,
remedy=acp_tool_gate.remediation_for(backend),
)
except acp_tool_gate.ToolGateUnroutable as exc:
raise AcpToolGateUnroutable(str(exc)) from None
return

try:
await self.set_config_option(option_id, value)
except AcpError as exc:
# The option was advertised and the write still failed, so this is an
# observed bypass rather than missing evidence.
try:
acp_tool_gate.enforce_runtime_routing(
backend,
"the adapter rejected its required session permission configuration",
verdict=acp_tool_gate.Verdict.BYPASSED,
remedy=acp_tool_gate.remediation_for(backend),
)
except acp_tool_gate.ToolGateUnroutable as gate_exc:
raise AcpToolGateUnroutable(str(gate_exc)) from exc
return

logger.info(
"ACP permission route armed: %s=%s (%s)",
option_id,
value,
acp_tool_gate.label_for(backend),
)

def _store_session_config(self, resp: dict) -> None:
"""Extract effort configOptions from a session/new or session/load response.

Expand Down Expand Up @@ -3721,6 +3820,13 @@ async def _spawn(self) -> None:
if self.backend in ACP_BACKENDS_INTERNAL_SANDBOX:
await asyncio.to_thread(assert_voice_runtime_outside_agent_workspace, self._work_dir)

# Credential mask for an enforced adapter, resolved inside that adapter's
# own branch below. Declared here only because wrap_argv_async takes it as
# one argument for every harness; the kiro branch never assigns it, so the
# kiro construction path gains no conditional, no awaited step and no new
# failure point in service of an adapter (harness-parity H13).
adapter_hidden_dirs: tuple[str, ...] = ()

if self._is_claude:
# Fold the requested model onto the exact spelling claude-agent-acp
# advertised (from the persisted provider-model cache warmed by a
Expand Down Expand Up @@ -3801,6 +3907,26 @@ async def _spawn(self) -> None:
f"The 'codex' CLI alone does not serve ACP."
)
argv = codex_argv
# Fail closed BEFORE the spawn when the mask below would be dropped:
# several wrap_argv paths return without applying extra_hidden_dirs,
# which would start an enforced adapter with no compensating control
# at all. Placed inside this pre-existing codex arm rather than in a
# gate of its own on the shared path: harness-parity H13 asks whether
# the kiro path CHANGED, and a conditional or an awaited step added
# there in service of an adapter is the change it names -- so the
# adapter's work lives entirely behind the adapter's own seam.
# Keyed on the ROUTING, not on codex's identity: _sandbox_preflight
# re-checks acp_tool_gate.is_enforced(self.backend) itself, so this
# site cannot mask a harness this core does not enforce. A future
# SESSION_CONFIG harness gets its own arm here and must make the same
# call; test_acp_tool_gate ratchets that so it cannot be forgotten.
# OFF-LOOP: both halves touch the filesystem -- the refusal probes for
# a sandbox backend (a cold probe shells out via subprocess.run) and
# the mask resolves the home plus every env-override root -- so they
# run in ONE worker thread rather than blocking the gateway loop.
adapter_hidden_dirs = await asyncio.to_thread(
_sandbox_preflight, self.backend, self._sandbox_mode
)
else:
# Pin ONE reading of the environment for both the search and the
# message that reports it. The previous code resolved against the live
Expand Down Expand Up @@ -3851,6 +3977,10 @@ async def _spawn(self) -> None:
argv,
mode=self._sandbox_mode,
strip_python_env=True,
# Credential homes the standard tier exposes for kiro-cli's sake and
# that an enforced adapter has no claim on. Empty for every harness
# this core does not enforce, so their spawn arguments are unchanged.
extra_hidden_dirs=adapter_hidden_dirs,
is_kiro_cli=self.backend in ACP_BACKENDS_INTERNAL_SANDBOX,
_prepare=wrap_argv,
)
Expand Down Expand Up @@ -4697,6 +4827,17 @@ async def _initialize_session(self) -> None:
# 5. Set model — override if KiroCrew config specifies non-default.
await self._apply_startup_model()

# 6. Arm permission routing for harnesses whose asking is a session
# config option. AFTER the model apply (both write config options, and
# the permission one must land last) and before any prompt can run:
# _initialize_session is entirely pre-prompt, which is what makes this
# placement the guarantee rather than a best effort.
# Gated HERE rather than inside the method, so the first-class Kiro path
# gains no call, no await and no failure point in service of an adapter
# (harness-parity H13). A positive membership test, never "not claude".
if acp_tool_gate.routing_for(self.backend) is acp_tool_gate.Routing.SESSION_CONFIG:
await self._apply_session_permission_routing()

# Drain MCP server init notifications
await self._drain_notifications()

Expand Down Expand Up @@ -4748,6 +4889,18 @@ async def ensure_ready(self) -> None:

_startup_outcome = "ready"
return
except AcpToolGateUnroutable:
# Non-retryable BY CONSTRUCTION (see the class docstring): the
# refusal is a configuration fact, so a respawn re-reads the same
# answer and refuses again -- a wasted spawn plus teardown that
# also spends the reconnect budget this DISTINCT type exists to
# protect. Must sit BEFORE the generic transport handler, because
# it subclasses AcpError and would otherwise be retried by it,
# which is the shape that made the distinct type decorative.
_startup_outcome = "tool_gate_unroutable"
await self._cleanup_failed_live_spawn()
self._reset_state()
raise
except (AcpTimeoutError, AcpError) as exc:
if attempt == 0:
logger.warning("ACP init failed (%s), retrying with fresh process...", exc)
Expand Down
Loading
Loading