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
28 changes: 22 additions & 6 deletions docs/design/agent-workflows/documentation/adapters/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,28 @@ attached. See [tools.md](../tools.md#status-and-known-gaps).

## Permissions

Claude gates tool use behind a permission prompt. In an Agenta run there is no human at the
keyboard to answer it, so the runner answers for it. By default it auto-approves, because the
tools are backend-resolved and trusted. The per-run permission policy (or an env override)
can flip this to deny, which rejects tool use instead. This is handled on
`session.onPermissionRequest`, a hook Pi does not need because Pi does not gate tools this
way.
Claude gates tool use behind its own permission prompt. There are two places a per-tool
permission lands, and both matter.

The first is static, written before the session starts: the claude adapter renders a
`.claude/settings.json` file (`sdks/python/agenta/sdk/agents/adapters/claude_settings.py`,
delivered on the `harnessFiles` wire seam) whose `permissions.allow` / `permissions.ask` /
`permissions.deny` lists Claude Code reads via `settingSources`. Each backend-resolved EXECUTABLE
tool (callback/code) gets a per-tool rule `mcp__agenta-tools__<name>`, because that is how Claude
addresses a tool of the internal `agenta-tools` MCP server above. A tool whose
`effective_permission()` is `allow` gets an allow rule, so Claude runs it without raising a gate;
`deny` gets a deny rule; `ask` or unset gets no allow rule, so the gate still fires.

The second is dynamic: a gate that does fire arrives at `session.onPermissionRequest`, where the
runner answers for the (absent) human. When a human surface exists the runner parks the undecided
gate for a HITL approval round-trip; otherwise it falls to the per-run permission policy (auto or
deny). Pi does not need this hook because Pi does not gate tools this way.

Both layers are needed because Claude's gate fires BEFORE the runner relay that would otherwise
honor an `allow`. Without the settings.json rule, an `allow` resolved tool always parked
(finding F-046); the rule is what lets it run. The `ask`/unset path is left to the gate on
purpose, so HITL approval is preserved. Note that `permission_policy: "auto"` is NOT a blanket
bypass — it still means "gate, then HITL or policy", not "allow everything".

## Tracing from the event stream

Expand Down
2 changes: 1 addition & 1 deletion docs/design/agent-workflows/interfaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ page. `Status` is read from each page's prose: **stable** (wired and unlikely to
| [Neutral runtime DTOs](in-service/neutral-runtime-dtos.md) | in-service | `agents/dtos.py` | stable | `unit/agents/test_dtos_*.py`, `test_harness_identity.py` |
| [Runtime ports](in-service/runtime-ports.md) | in-service | `agents/interfaces.py` | evolving (`LocalBackend` stub) | `unit/agents/test_environment_lifecycle.py`, `test_harness_adapters.py` |
| [Backend adapter](in-service/backend-adapter.md) | in-service | `agents/adapters/sandbox_agent.py` | stable | `unit/agents/test_runner_adapter_config.py`, `test_environment_lifecycle.py` |
| [Harness adapters](in-service/harness-adapters.md) | in-service | `agents/adapters/harnesses.py`, `agents/dtos.py` | stable | `unit/agents/test_harness_adapters.py`, `test_dtos_harness_configs.py` |
| [Harness adapters](in-service/harness-adapters.md) | in-service | `agents/adapters/harnesses.py`, `agents/adapters/claude_settings.py`, `agents/dtos.py` | stable | `unit/agents/test_harness_adapters.py`, `test_dtos_harness_configs.py`, `unit/agents/adapters/test_claude_settings.py` |
| [Browser protocol adapter](in-service/browser-protocol-adapter.md) | in-service | `agents/adapters/vercel/{routing,messages,stream,sse}.py` | stable | `unit/agents/test_ui_messages.py`, `utils/test_messages_endpoint.py` |
| [Tool models and resolution](in-service/tool-models-and-resolution.md) | in-service | `agents/tools/models.py`, `platform/gateway.py`, `agent/tools/resolver.py` | stable | `unit/agents/tools/` |
| [MCP models and resolution](in-service/mcp-models-and-resolution.md) | in-service | `agents/mcp/{models,resolver,wire}.py` | evolving (stdio wired; remote deferred; resolution feature-gated) | `unit/agents/mcp/` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ Each adapter implements `_to_harness_config(...)` and emits a different `/run` w
overrides (`system`, `append_system`), and drops `permission_policy` because Pi never gates
tool use.
- **`ClaudeHarness`** delivers tools over MCP, not natively, and has no Pi built-ins (it warns
if any are set). It carries `permission_policy` and renders `.claude/settings.json` from
`harness_kwargs` and the sandbox permission, shipped as `harnessFiles`. It carries inline
skill packages on the wire like the others; the runner materializes them under
if any are set). It carries `permission_policy` and renders `.claude/settings.json` from four
sources — the author's `harness_kwargs["claude"]["permissions"]` slice, the sandbox permission,
each user MCP server's permission (`mcp__<server>` rules), and each resolved EXECUTABLE tool's
permission (`mcp__agenta-tools__<name>` rules; F-046) — shipped as `harnessFiles`. It carries
inline skill packages on the wire like the others; the runner materializes them under
`.claude/skills` in the session cwd, matching Claude's project-local skill layout.
- **`AgentaHarness`** runs on the same Pi engine but forces Agenta's opinion: it composes the
base instructions over the author's, forces the Agenta tool set, and layers the Agenta
Expand Down Expand Up @@ -54,3 +56,10 @@ The wire shapes, side by side:
now pins the carry-on-wire behavior.)
- **Harness options.** The `harness_kwargs` bag is keyed by harness; each adapter reads only
its own slice.
- **Claude `agenta-tools` server-name coupling.** The per-resolved-tool settings.json rules use
the fixed name `mcp__agenta-tools__<tool>` (`INTERNAL_TOOL_MCP_SERVER` in
`adapters/claude_settings.py`). It MUST match the runner's internal tool-MCP server name
(`services/agent/src/tools/mcp-bridge.ts`, `relay-mcp-stdio.ts`, `tool-mcp-http.ts`,
`engines/sandbox_agent/mcp.ts`, `sdks/python/agenta/sdk/agents/adapters/claude_settings.py`).
Renaming the server on one side without the other silently
re-parks `allow` tools on Claude (the bug F-046 fixed).
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,20 @@ are two cases.

Resolved tools (gateway, code) run in the runner, through the relay. The runner is the choke
point, so it applies the permission directly: allow runs the call, ask parks it, deny
refuses it. This works the same on `pi` and `claude`.
refuses it.

This is true on `pi`, but it was NOT true on `claude` until F-046 was fixed — and the fix
shows why the relay alone is not enough on Claude. A resolved executable tool is delivered to
Claude as a tool of the internal `agenta-tools` MCP server (`mcp__agenta-tools__<name>`), and
**Claude Code raises its own permission gate before the tool ever reaches the runner relay**.
The runner parks every undecided gate when a human surface exists, so the per-tool `allow` the
relay would have honored is never consulted — an `allow` tool always parked. The fix renders the
per-resolved-tool permission as a Layer-1 `.claude/settings.json` rule too
(`adapters/claude_settings.py` `_rules_from_tool_specs`): `allow` -> a `permissions.allow` rule
(Claude runs it, no park), `ask`/unset -> no allow rule (the gate stays raised, HITL park
preserved; `client` tools excluded because they are not delivered over the internal MCP server),
`deny` -> a deny rule. So on Claude the permission is enforced at the settings layer
(before the gate) AND at the relay; on `pi` the relay is the sole choke point.

Harness builtins (Claude `Bash`/`Edit`/`Read`, Pi `bash`/`read`) run inside the harness, where
the runner cannot intercept them. For Claude, the settings.json `allow`/`deny`/`ask` rules set
Expand Down
32 changes: 30 additions & 2 deletions docs/design/agent-workflows/projects/capability-config/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ its seven points:
`network: off` guarantee. See `research.md` section 5.
2. **`read_only` enforcement strength.** Is `read_only` a hard runtime block on resolved tools,
or only an advisory default for the permission? The honest first cut is advisory.
- ~~**Resolved-tool `permission:"allow"` parks on Claude (F-046).**~~ **Resolved by the F-046
fix.** A resolved executable tool's per-tool permission is now rendered as a
`mcp__agenta-tools__<name>` rule in `.claude/settings.json`, so an `allow` tool runs (no park)
while `ask`/unset keeps the HITL gate. The relay stays the choke point on `pi`. (See the S3b
"F-046 correction" note above and `proposal.md` §"Where Layer 3 is enforced".) Still open: the
batch `/invoke` sub-observation (HTTP 200 + empty content on a parked `ask` tool) — separate
from this permission fix.
3. ~~**Pi's real control surface (Phase 0).**~~ **Resolved by S0b.** Backend-dependent: supported
in-process (`pi.ts:311` passes `tools`), unsupported over sandbox-agent ACP (`pi-acp@0.0.29`
forwards nothing). Design: honor `builtin_names` in-process, fail loud over sandbox-agent.
Expand Down Expand Up @@ -218,11 +225,21 @@ Smallest shippable, independently reviewable units. Each names its acceptance ch
- **S3b — Layer 3 enforcement** (`done`, reviewed). Relay enforces resolved-tool permission
(`resolvePermission`: deny→refusal string before any execution, allow→run, ask/unset→headless
`permissionPolicy`); `permissionPolicy` threaded into `startToolRelay`, same resolver as the
Claude-builtin responder. `claude-settings.ts` renders per-MCP-server `mcp__<server>` rules
Claude-builtin responder. The claude adapter (now Python: `adapters/claude_settings.py`, formerly
`claude-settings.ts`) renders per-MCP-server `mcp__<server>` rules
(name verified against `toAcpMcpServers`). Responder untouched (Claude builtins handled by S2
settings.json + existing `PolicyResponder`); no fragile per-resolved-tool mcp rules. HITL "ask"
settings.json + existing `PolicyResponder`). HITL "ask"
surfacing deferred to S5 (`TODO(S5)`). 166 TS pass. Reviewer APPROVED (deny-before-execute +
MCP-name both verified against downstream consumers).
- **F-046 correction (2026-06-27).** S3b originally chose "no per-resolved-tool mcp rules",
on the premise that the relay is the choke point for resolved tools on every harness. That
premise was FALSE for Claude: a resolved executable tool is delivered as a tool of the internal
`agenta-tools` MCP server, and Claude Code's own permission gate fires BEFORE the relay, so the
runner parks it and the relay's `allow` is never consulted — a `permission:"allow"` tool ALWAYS
parked on Claude. Now WIRED: `_rules_from_tool_specs` in `adapters/claude_settings.py` renders a
per-tool `mcp__agenta-tools__<name>` rule (allow→`permissions.allow`, ask/unset→no allow rule so
HITL park is preserved, deny→`permissions.deny`; `client` tools excluded). This does NOT make
`permission_policy:"auto"` a blanket bypass (that is a separate design question, out of scope).
- **S4 — Playground form** (`done`, reviewed + fixed). New controls: `SandboxPermissionControl`
(network mode/allowlist/filesystem/enforcement), `ClaudePermissionsControl` (mode +
allow/deny/ask, gated to Claude harness, collapsible advanced), per-tool `ToolPermissionControl`
Expand Down Expand Up @@ -320,6 +337,17 @@ gh pr create --head feat/agent-capability-config --base main
`engines/pi.ts` (rejects restrictive sandbox_permission + deny/ask permissions), 8 tests, live
rejection confirmed. 185 TS green. Note: `engines/pi.ts` is in the shared-files set (carries the
guard); test `pi-capability-guard.test.ts` is new.
- 2026-06-27: Fixed F-046 (Claude resolved-tool permission). The Layer-1 claude-settings renderer
(`adapters/claude_settings.py`) now emits a per-resolved-tool `mcp__agenta-tools__<name>` rule for
each EXECUTABLE (callback/code) tool, mapping `effective_permission()` → allow/ask/deny — mirroring
the per-MCP-server helper but against the runner's fixed internal `agenta-tools` server name (new
`INTERNAL_TOOL_MCP_SERVER` constant, coupled to `services/agent/src/tools/mcp-bridge.ts`).
`ClaudeAgentConfig.wire_harness_files` now passes `tool_specs` in. A `permission:"allow"` tool
runs on Claude instead of parking; `ask`/unset preserves the HITL park; `deny` blocks; `client`
tools excluded. Corrected the false S3b premise + the proposal claim that resolved-tool permission
"works the same on pi and claude". SDK claude-settings unit tests + the Claude golden + both wire
contract tests (Python + TS) updated. `permission_policy:"auto"`-as-blanket-bypass intentionally
NOT done (separate design question).
- 2026-06-24: Landed Layers 1 + 3 + the playground (code-complete, reviewed). S2 (Claude
`.claude/settings.json`, reviewed), S3a (permission plumbing), S3b (relay + MCP-rule
enforcement, reviewed), S4 (playground form, reviewed) + S4b (fixed the `agenta_metadata`-strip
Expand Down
Loading
Loading