Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
6 changes: 6 additions & 0 deletions docs/design/agent-workflows/documentation/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,9 @@ Request fields include:
One-shot calls return one JSON result. Streaming calls use NDJSON internally: one
`{"kind":"event"}` record per live event, followed by one `{"kind":"result"}` terminal
record. The browser never sees this NDJSON directly; `/messages` converts it to Vercel SSE.

This page covers the `/run` wire only. A separate internal channel, the relay directory the
runner shares with the sandbox (used for Daytona tool calls and, now, for Pi builtin
permission checks), recently gained a second record kind. See
[Tools](tools.md#built-in-tools-the-harness-runs-them-natively-gated-through-the-same-relay)
for the permission record shape.
34 changes: 26 additions & 8 deletions docs/design/agent-workflows/documentation/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,13 +325,27 @@ The pause itself is shared by both delivery paths through one seam
A client tool's `render` hint can be `{ kind: "connect" }` (e.g. `request_connection`), the typed
member of `RenderHint` that asks the frontend to draw the connect widget.

### Built-in tools: the harness runs them natively
### Built-in tools: the harness runs them natively, gated through the same relay

Execution is the harness's own. A built-in tool is just a name. The runner adds it to the
session's allowlist and Pi runs its own implementation of `read`, `write`, `web_search`, and so
on. Nothing is resolved and nothing is delivered. Note that built-ins are a Pi concept here;
they are not delivered to non-Pi harnesses over ACP, which bring their own native tool set.

Pi's builtins now flow through the same permission relay as gateway and code tools. The
bundled Pi extension's `tool_call` hook reports every builtin call over the relay directory as
a permission record (`kind: "permission"`), and the runner decides it through the same shared
`decide()` in `permission-plan.ts` the relay already uses for gateway and code tools. An `ask`
verdict pauses the turn exactly like a relay tool does. The extension hook then maps a
non-allow verdict to Pi's own `{ block: true }`, because Pi, not the runner, is the thing that
would otherwise execute the call.

The grant list (the wire `tools` field: the builtins an author selected) is enforced
separately, at session start. The extension edits Pi's active tool set at
`before_agent_start`, replacing only the builtin slice with the granted names and leaving
every non-builtin tool untouched. A builtin outside the grant list is simply absent from the
model's active tools, so no call for it ever fires, and the permission hook never sees it.

### MCP servers: a server process the daemon launches

Execution happens in a separate server process. A declared MCP server is resolved server-side
Expand Down Expand Up @@ -371,10 +385,12 @@ Two gates consult this same decision:
raises these today: it checks its own settings file first, and only an undecided call reaches
the responder.
- **The tool relay**, `services/runner/src/tools/relay.ts`, enforces permission on tools the
runner executes directly (gateway, code). It only needs to, because on Claude the harness
settings file plus the ACP responder already decide before a call reaches the relay. On Pi
there is no harness-side gate, so the relay is the only enforcement point, and it now gives Pi
the same human-in-the-loop behavior Claude gets.
runner executes directly (gateway, code) and, now, on Pi's own builtins, relayed through the
bundled extension's `tool_call` hook. It only needs to, because on Claude the harness settings
file plus the ACP responder already decide before a call reaches the relay. On Pi there is no
separate harness-side settings gate, so the relay is the enforcement point for everything Pi
runs, including its native builtins, and it gives Pi the same human-in-the-loop behavior
Claude gets.
Comment on lines +389 to +394

@coderabbitai coderabbitai Bot Jul 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm current status of code tools in run-plan.ts / code.ts
rg -n "CODE_TOOL_UNSUPPORTED_MESSAGE|hasCodeTool" services/runner/src -A3

Repository: Agenta-AI/agenta

Length of output: 3359


Drop the code-tool relay path from this doc
These passages still describe the relay as enforcing code tool execution, but buildRunPlan rejects code tools up front and runCodeTool is only a backstop. Remove the code references here and in the later duplicate passage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 189ae48: tools.md no longer implies the relay executes code tools today (they are declared but unsupported in the runner; CODE_TOOL_UNSUPPORTED_MESSAGE).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "code" docs/design/agent-workflows/documentation/tools.md -i | grep -i "relay" 
echo "---"
rg -n "The tool relay" -A6 docs/design/agent-workflows/documentation/tools.md
echo "---status section---"
rg -n "Code tools" -A4 docs/design/agent-workflows/documentation/tools.md

Length of output: 2189


🏁 Script executed:

#!/bin/bash
rg -n "CODE_TOOL_UNSUPPORTED_MESSAGE|unsupported" docs/design/agent-workflows/documentation/tools.md -i
echo "---context 330-345---"
sed -n '330,345p' docs/design/agent-workflows/documentation/tools.md
echo "---context 525-545---"
sed -n '525,545p' docs/design/agent-workflows/documentation/tools.md
echo "---git log---"
git log --oneline -5 -- docs/design/agent-workflows/documentation/tools.md

Length of output: 3804


@mmabrouk Thanks for the fix — the builtin-gating paragraph (around Line 335) now correctly notes that code tools are "declared but not yet executable in the runner." However, the other two spots I flagged still carry the same implication and weren't updated:

  • Line 389 (under "Two gates consult this same decision" → "The tool relay" bullet): "enforces permission on tools the runner executes directly (gateway, code)" — still lists code as something the relay directly executes today.
  • Line 537 (under "Status and known gaps"): "the relay decides everything Pi runs: gateway and code tools directly, and builtins relayed through the extension's tool_call hook" — same issue.

Both of these still read as if code tools are actively executed/enforced by the relay path, which contradicts the fact that buildRunPlan rejects code tools up front and runCodeTool is unsupported (CODE_TOOL_UNSUPPORTED_MESSAGE). Could you drop the code mention from these two remaining spots as well, to be consistent with the fix already applied at Line 335?


Client tools are a carve-out from that same ladder. They are decided by the responder's
`onClientTool` (consulted at the ACP gate on Claude, and by the relay on Pi), not by the
Expand Down Expand Up @@ -515,9 +531,11 @@ The contract and the field-by-field Composio→Agenta mapping live in the
Agenta are the default harnesses, so `mcp_servers` is a silent no-op for most runs. It would
reach Claude only. Do not confuse this with the `agenta-tools` server, which is an internal
tool-delivery vehicle for Claude, not a user MCP server.
- A tool's `permission` is honored on both harnesses now. Claude checks its rendered settings
file first, then the ACP responder; Pi has no native gate, so the relay enforces it directly.
An `ask` pauses the run and asks a human on either harness.
- A tool's `permission` is honored on both harnesses now, including Pi's own builtins. Claude
checks its rendered settings file first, then the ACP responder. Pi has no separate
harness-side settings gate, so the relay decides everything Pi runs: gateway and code tools
directly, and builtins relayed through the extension's `tool_call` hook. An `ask` pauses the
run and asks a human on either harness.
- Gateway tools support only the `composio` provider today; other providers raise.
- The `render` hint is plumbed end to end on the runner side, but full frontend projection of
every render kind is still in progress.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ because the effective permission is resolved before the stored decision is consu
`ApprovalResponder` is Gate 2, for tools the harness gates natively (Claude Code's
`.claude/settings.json` layer decides first; anything it leaves undecided reaches this
responder over ACP). The tool relay (`services/runner/src/tools/relay.ts`) is Gate 3, for
tools the runner executes itself (gateway, code, client). Both gates call the same
tools the runner executes itself (gateway, code, client) and, since the pi-builtin-gating
slice, for Pi's own builtins too: the bundled Pi extension's `tool_call` hook reports each
builtin call over the relay directory as a permission record, and the relay decides it
through this same `decide()` before answering. Both gates call the same
`effectivePermission`/`decide` pair from `permission-plan.ts`, so they can never disagree
about a tool's permission. The relay only needs to enforce on Pi, since Claude's Gate 1 and
Gate 2 already decide before a call reaches the relay.
Expand Down
23 changes: 16 additions & 7 deletions docs/design/agent-workflows/projects/approval-boundary/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,22 @@ analysis that diagnosed the live loop.
the terminal result's value; the live stream path should too. Display is unaffected (the
FE keys off the `interaction_request` part), so this is wire-fidelity, not UX.

- **Selection-time enforcement for Pi builtins.** Pi's native tools never reach a
runner gate, so today the global policy modes do not bind them (headless QA proved
`default: deny` does not stop a granted `bash`). The design: filter `builtin_names`
at resolution by effective permission, using a small read-only table for Pi's seven
builtins (`read/grep/find/ls` read, `bash/edit/write` write); `deny` and un-pausable
`ask` exclude the builtin (deny-by-omission is Pi's one native control). Until then,
a per-builtin `permission` is dropped with a logged warning instead of silently.
- **Selection-time enforcement for Pi builtins. SHIPPED**, by the pi-builtin-gating slice
(implemented and live-QA'd 2026-07-04). At the time this follow-up was written, Pi's
native tools never reached a runner gate, so the global policy modes did not bind them.
The shipped design supersedes the selection-time sketch below with call-time gating: the
bundled Pi extension's `tool_call` hook reports each builtin call over the relay
directory, and the runner decides it through the same shared `decide()` the relay already
used for gateway and code tools, pausing on `ask` exactly like any other tool. The grant
list (which builtins an author selects) is still enforced at selection time, through the
extension's active-tool-set edit at `before_agent_start`, so a non-granted builtin is
simply absent from the model's tools. See
`docs/design/agent-workflows/projects/pi-builtin-gating/` for the full design and status.
The original sketch, for context: filter `builtin_names` at resolution by effective
permission, using a small read-only table for Pi's seven builtins (`read/grep/find/ls`
read, `bash/edit/write` write); `deny` and un-pausable `ask` exclude the builtin
(deny-by-omission is Pi's one native control). Until then, a per-builtin `permission` is
dropped with a logged warning instead of silently.

## Known unknowns

Expand Down
53 changes: 53 additions & 0 deletions docs/design/agent-workflows/projects/pi-builtin-gating/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Pi builtin gating: route native tools through the permission relay

Pi ships seven native builtin tools (`read`, `bash`, `edit`, `write`, `grep`, `find`,
`ls`). Before this work they ran outside our permission system. The author could not say
"ask before `bash`", and the "which builtins are enabled" list the UI shows did nothing.
This workspace explains why, picks a design, and lays out the build.

This PR now ships BOTH the workspace and the implementation: the runner-side permission
record and builtin identity table, the extension's policy hook and grant enforcement, the
env plumbing, and the test suite. status.md tracks the live state (implemented,
live-QA'd, S1 5/5 bar met); build-notes.md records how the build actually went. The two
gaps below are written in the present tense of the design phase; they are both closed.

## The two gaps

1. **`bash: ask` is inexpressible.** Our permission plan supports `allow | ask | deny` per
tool and four global modes. Custom tools and Claude's own tools honor it. Pi's seven
builtins never reach a gate, so any authored rule on them is silently ignored. An agent
that should pause before running a shell command just runs it.

2. **The builtin grant list is dead.** The run request carries `tools?: string[]`
("Built-in tools to enable", `services/runner/src/protocol.ts:423`). The SDK still emits
it and the playground still writes the author's selection into it. Nothing in the runner
reads it. It went dead on 2026-06-24 in commit `0e71bd0f7a`, which deleted the old
in-process engine that was its only consumer. So every builtin is always on, whatever the
author selected.

## The design in three sentences

Our Pi extension gains a `tool_call` hook. Pi fires it before every builtin runs, and the
hook can block. For a granted builtin, the hook asks the runner for a decision through the
existing relay-directory file protocol, the runner decides with the real tool name and real
arguments in the one shared decision module (`decide()` in
`services/runner/src/permission-plan.ts`), and the hook blocks or allows on the answer.

## Reading order

1. [context.md](context.md): why this matters and how it ties to the approval-boundary work.
2. [research.md](research.md): the verified facts about the code this builds on, with
file and line references.
3. [design.md](design.md): the relay record shape, the decision mapping, the env config,
grant-list enforcement, and the pause/resume flow and its failure modes.
4. [plan.md](plan.md): the phased build, tests, and the live spike that de-risks the design.
5. [status.md](status.md): current state, the decision log (Option B over A and C), and
open risks.

## The decisions to weigh in on

- The shape of the new permission record on the relay protocol (design.md). A Codex round
already settled the record into a discriminated union and the gate onto the existing
`"harness"` executor; the remaining call is whether that shape reads right to you.
- How to mitigate the re-issue risk if Pi treats a blocked call as terminal (design.md,
plan.md Phase 0). This is the top open risk and the reason Phase 0 is a spike, not code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

## 2026-07-04 implementation run

- **Phase 0 stage-A spike PASSED** (see status.md): cross-turn re-issue 3/3 with identical
args; the match projection was born from the one drift case (a spontaneous `timeout`
param on bash).
- **Phases 1-4 implemented** (Codex xhigh implemented, orchestrator reviewed each diff;
Sonnet wrote the Phase 4 cross-cutting tests). Runner suite grew 444 -> 525, tsc clean,
extension bundle rebuilt. Key review confirmations: `approvedCallKey` routes through the
shared `storedDecisionKeyShape`, so the gate side and the transcript-extraction side can
never disagree on the resume key; the relay permission branch writes verdicts verbatim on
every path; the run-plan predicate honors the kill switch and the `undefined` vs `[]`
grant distinction.
- **Live QA immediately caught the design's own predicted failure class.** First S1 run
showed zero gating. Not stale runner code: the dev sidecar mounts only `src/` from the
worktree, so the runner computed gating correctly while the Pi extension installed into
each sandbox came from the image's BAKED `dist/` bundle (dated July 1, no hook). New
runner + old extension is the silent direction of the version skew: the old bundle never
writes a permission record, so the `protocol: 1` pin has nothing to reject. Fixed on the
box by copying the fresh bundle into the container (survives restart, not recreation).
Durable fixes noted: mount or build `dist/` in the sidecar recipe, and a possible
runner-side hardening (fail loud when a gating-active Pi turn ends with zero permission
records for a policy that must gate) filed as a follow-up candidate.
- **Concurrency note:** implemented alongside mcp-mvp-claude's live client-tools work in
the same worktree. Six files' hunks (relay.ts, responder.ts, agenta.ts, sandbox_agent.ts,
two tests) are frozen worktree-only until their lane merges; the lane is deliberately
unpushed while its tip would not compile standalone. Board rows carry the handshake.
70 changes: 70 additions & 0 deletions docs/design/agent-workflows/projects/pi-builtin-gating/context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Context: why gate Pi's builtins

## What a builtin is

Pi runs an agent loop with seven tools built into the harness: `read`, `bash`, `edit`,
`write`, `grep`, `find`, and `ls`. The model calls them the same way it calls a custom tool.
The difference is where they execute. A custom tool runs through our code. A builtin runs
inside Pi itself, and Pi never asks us for permission first.

## Gap 1: the author cannot gate a builtin

Our permission model is small and clear. An author sets one global default and optional
per-tool rules. Each tool resolves to `allow`, `ask`, or `deny`. The four global modes are
`allow`, `ask`, `deny`, and `allow_reads` (reads run, everything else asks). This model is
described in the approval-boundary workspace at
[projects/approval-boundary/how-approvals-work.md](../approval-boundary/how-approvals-work.md).

The model works for every tool that reaches a gate. Claude's tools reach a gate because
Claude asks over its own protocol. Our custom tools reach a gate because we execute them
and check first. Pi's builtins reach no gate at all. So `bash: ask` does nothing on Pi. The
shell command runs. This is the sharpest gap, because `bash` is the one builtin most authors
would want to pause on.

## Gap 2: the grant list stopped working

The run request has a field for the enabled builtins: `tools?: string[]` at
`services/runner/src/protocol.ts:423`, commented "Built-in tools to enable". The SDK fills
it from the author's selection (`PiAgentTemplate.wire_tools` in
`sdks/python/agenta/sdk/agents/dtos.py:829` emits `"tools": list(self.builtin_names)`), and
the playground's Pi settings control writes that selection.

The runner ignores the field. It went dead in commit `0e71bd0f7a` (2026-06-24, "remove
legacy in-process backend"). That commit deleted the old in-process engine, and that engine
held the only code that read the list. So the selection an author makes in the UI has no
effect. Every builtin is always available. The commit message is honest that it removed the
in-process path as dead code; the grant list was collateral, and no one has re-wired it since.

## Why the two gaps share one fix

Both gaps are the same missing thing: a point where the runner sees a builtin call before it
runs and gets to decide. Add that one interception point and both gaps close. A non-granted
builtin gets refused. A granted builtin gets the author's `allow | ask | deny`.

## Tie to the approval-boundary redesign

The approval-boundary workspace ([projects/approval-boundary/](../approval-boundary/))
established a doctrine we follow here: **one decision module.** The rule about whether a tool
runs, pauses, or is denied lives in exactly one place, `decide()` in
`services/runner/src/permission-plan.ts`, and every gate calls it. That workspace's plan
([projects/approval-boundary/plan.md](../approval-boundary/plan.md)) lists the gates: the SDK
settings renderer for Claude, the ACP responder for Claude's live gates, and the relay for
tools the runner executes itself.

Builtin gating adds a fourth caller of the same `decide()`. It does not add a second policy
engine. The whole point of the design below is that the runner decides builtins with the same
function, the same `GateDescriptor`, and the same stored-decision resume machinery that
custom-tool asks already use. The extension only reports the call and enforces the answer.
This keeps the doctrine intact: the sandbox never decides policy; it asks the runner.

## Non-goals

- No new policy language. Authors express builtin rules with the existing `allow | ask |
deny` and the four global modes.
- No per-builtin permission field on the config yet. The SDK currently drops a per-builtin
permission loudly (`BuiltinToolConfig._drop_unenforceable_permission` in
`sdks/python/agenta/sdk/agents/tools/models.py:87`) because nothing enforced it. This
design makes enforcement possible, so re-enabling that field becomes a small follow-up, but
it is out of scope here. Authors still gate builtins through global-mode rules and pattern
rules (for example `bash(git:*)`).
- No change to Claude. Claude already gates its own tools.
Loading
Loading