fix(mcp): gate the loop tools on the agent permission edge, and make the gate a mechanism (abilityai/trinity-enterprise#628) - #2826
Conversation
…the gate a mechanism (Abilityai/trinity-enterprise#628) `run_agent_loop` resolved its target from the caller's parameter and called no gate, and the backend behind it resolves an agent-scoped key to its owner (Invariant #8) — so an agent key could start a loop on any same-owner sibling with no `agent_permissions` edge. Verified live: the loop ran on the sibling. The per-tool gate had ten spellings across nine modules and the tool added last called none of them, so this closes the class, not the tool. - src/mcp-server/src/access.ts (new): ONE implementation of the agent-scope edge (`checkAgentEdge` — system bypasses; an agent key reaches itself and its permitted targets, the permitted list read fail-closed; a user key passes through because the backend already scopes it by role and per-user grant; any other scope is denied — an allowlist, #2323); `TOOL_ACCESS_POLICY`, one row per registered tool (enforce / in-tool / baselined:<owner> / none:<why>); `policyFor` (no row, an enforce on an undeclared parameter, or a none on a tool whose parameters name an agent throws at registration); and `withAgentAccess`, the enforce wrapper. - server.ts: every tool passes `policyFor`; enforce rows are wrapped before `withAudit`; dynamic tools declare their policy as an argument. - tools/loops.ts: `run_agent_loop` is an enforce row; `get_loop_status` / `stop_loop` resolve the loop's agent, gate, then act — a denial withholds the payload behind a compound uniform reason (the id was the caller's only input), and a failed resolve sends no stop and names the escape hatch. - tools/chat.ts: the agent/system branch delegates to the shared gate; unknown scopes are denied before the user branch (closes a fall-through that promoted an unnamed agent key to the same-owner rule); `resolveClient` moves to access.ts. types.ts / client.ts: `LoopStatus`, `getLoopStatus` typed. - .github/workflows/mcp-server-test.yml: an offline boot smoke of dist/server.js — bundler module resolution hides a missing `.js` until the container dies. - tests: access.test.ts (createServer boots against the real table, every row names a tool and every tool has a row; policyFor refusals; the wrapper never reaches execute on a denial; the read fails closed), tools/loops.test.ts (all three tools through the real row and wrapper with a fake client; a denial means the side-effecting call never happened; the reason is byte-identical to chat_with_agent's), J10: the strict xfail comes off, and a loop-id read or stop after the edge is removed is refused without naming the loop's agent while the owner can still stop it. - docs: the flow, the requirement (§38.1 said cross-agent loops were out of scope, the flow said "backend enforces", the code did neither), the architecture area file and the P-02 catalog entry now say one thing: a tool-surface gate at the MCP layer; the REST routes stay owner-equivalent, which is Abilityai/trinity-enterprise#629's ruling. The 53 `baselined` rows are that issue's work list. Learnings ledger +1; diff-scoped CSO report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dolho
left a comment
There was a problem hiding this comment.
/review + /cso --diff — head 0dd58bb2 vs merge-base 0bddbc95 — REQUEST CHANGES, one finding
Files: 22 (+1328/−174) · Scope: CLEAN — the gate, its table, the registration assertion, the loop-id pair, the docs, and the J10 flip; nothing outside ent#628. Lane: C (auth boundary in src/mcp-server).
The design is the right one and I want to say so before the finding: the class is exactly Invariant #8's admin-gate story one layer up (five patched call sites ⇒ the gate was wrong), and making the row a registration-time contract that the code can contradict — a tool with no row cannot boot, an enforce on a phantom parameter cannot boot, a none on a tool naming an agent cannot boot — is the mechanism, not another discipline. I re-ran the totality mutation myself: delete get_agent_a2a_card's row → createServer refuses, 4 red. checkAgentEdge is an allowlist with the unknown-scope arm on the deny side (#2323), connector.ts/auth.ts never route through chat.ts's gate so the "zero live behaviour change" claim holds by grep, list_channel_groups' baselined note names a real Telegram gap for ent#629 rather than hiding it, and the loop-id denial is the #186 shape. 406/406, boot smoke resolves, all checks green.
❌ C1 — the one line that makes the mechanism real has no executing test (Confidence 9/10)
src/mcp-server/src/server.ts:576-586:
const execute = policy.kind === "enforce" ? withAgentAccess(tool.name, tool.execute, policy, …) : tool.execute;
const wrapped: any = { ...tool, execute: withAudit(tool.name, execute, auditTargetId) };Mutation, restored from a scratch copy: change the second line to withAudit(tool.name, tool.execute, auditTargetId) — i.e. the wrapper is built and thrown away. npm test → 406 passed, 0 failed. run_agent_loop is ungated again and the suite is green.
Why: loops.test.ts:66-79 builds the composition itself — "run_agent_loop exactly as server.ts registers it: the real row, the real wrapper" — and access.test.ts:114 calls withAgentAccess directly. Both prove the wrapper works; neither proves addToolWithAudit applies it. The totality test (access.test.ts:51) boots createServer but reads builtinToolNames only. So the property the PR body leads with — an agent key with no edge cannot start a loop on a sibling — is delivered by unexecuted wiring, and your own mutation table's "wrapper bypass → red" must have been a different bypass than the one above. This is the #2811 class from yesterday's train (the reaction extracted and tested; the wiring that calls it not), and it is the load-bearing line here.
Fix: drive it through createServer. inline-auth-transport.test.ts already boots the server and calls tools over HTTP with a real client; add one case with an authenticate returning {scope: "agent", agentName: "a", mcpApiKey: …}, isAgentPermitted (or GET /api/agents/a/permissions) stubbed to [], call run_agent_loop with agent_name: "b", and assert the Access denied envelope — then the mutation above is red. One test; the harness exists.
Informational
- I1
withAgentAccesspasses an absent target through to the tool. Correct forrun_agent_loop(an agent key defaults to itself, a user key is told the parameter is required), and stated in the docstring — noting it because a futureenforcerow on a tool whose absent-parameter default is not self would gate nothing.policyForcannot see defaults; the row's reviewer has to. - I2 A returned denial is audited as a successful call — the PR names #2807 as the owner, and that is the right place for it, not here.
- I3
checkAgentEdgewith no auth context isallowed— dev-mode installs with noauthenticate; pre-existing behaviour moved, not widened.
Clean
- Auth: allowlist not denylist;
systembypass unchanged;userpasses through because the backend scopes it by role/grant this layer cannot see (#2824 named). No secret in any denial string; the loop-id denial withholds the agent name. - Order of operations:
stop_loopresolves → gates → stops; a failed resolve sends no stop and names the escape hatch (loops.test.tscase 3, executed). - Docs: six flow docs +
architecture/mcp-server.md+ requirements §38.1 + catalog P-02 + learnings, all in-diff;/cso --diffreport committed, 0 introduced. - J10: the ent#628 xfail is gone and the file's remaining two are #2806/#2807 as stated.
Setting status-needs-fix per #2815; it clears on your push.
|
merge-train: not on this train — changes requested, fix not yet pushed. The open Rides the next train once the test is pushed and the review is re-requested. |
|
Resolve by running |
… a real transport, an agent key, and a counted backend (Abilityai/trinity-enterprise#628) Review C1 on #2826: `server.ts::addToolWithAudit` composes `withAgentAccess` around an `enforce` row's `execute` and hands the result to `withAudit`. Both existing files proved the wrapper — `access.test.ts` calls it directly, `tools/loops.test.ts` builds the composition by hand — and neither executed the composition site. Build the wrapper there and discard it (`withAudit(tool.name, tool.execute, …)`) and the suite stayed at 406 green with `run_agent_loop` ungated: the #2811 class, the reaction tested and the wiring that calls it not. `access-wiring.test.ts` drives the tool the way an agent does: a real `createServer` in key mode, a real MCP client presenting an agent-scoped key over the streamable-HTTP transport, and a stub backend that answers `/api/mcp/validate` and the permission-edge read and COUNTS every `POST /api/agents/<target>/loops`. Without an edge the count stays at zero and the caller reads the denial; with an edge the loop starts; a self loop starts without a permission read. Under the reviewer's mutation the first case is the one red (`loopPosts` = ["bravo"], `success: true`); restored byte-identical, 409/409. Pattern: `inline-auth-transport.test.ts` (#2035). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
C1 addressed — the composition site now has an executing test (
|
dolho
left a comment
There was a problem hiding this comment.
Re-review — head 6591704e6 vs dev — APPROVE
What moved on the PR's side since 0dd58bb24: one file, src/mcp-server/src/access-wiring.test.ts; the gated sources (server.ts, access.ts, loops.ts, chat.ts) are byte-unchanged from the head I reviewed, and everything else in the range is dev arriving through the merge (58c0e4a26; learnings.md at 237 = dev's 236 + this PR's one, no markers).
C1 — closed, and I verified it the way it was found. The new test boots a real createServer in key mode, presents an agent-scoped key over the streamable-HTTP transport (the stub's /api/mcp/validate answers scope: "agent"), answers GET /api/agents/alpha/permissions, and counts every POST /api/agents/<target>/loops — three cases (no edge → refusal + zero posts; edge → one post; self → no permission read). On the PR head: npm test 409/409. Then my original mutation, replayed from a scratch copy — server.ts:586 handing tool.execute to withAudit, the wrapper built and discarded — now fails exactly one test, with the observable the gate exists for:
not ok 1 - an agent key with no edge is refused at the REGISTERED tool, and no loop starts
expected a refusal, got: {"success":true,"loop_id":"loop_1","agent_name":"bravo",…}
server.ts restored byte-identical (cmp), 409/409 again. The load-bearing line has an executing test that reads the backend's own receipt, not the tool's return value — the #2829 bar, met.
I1–I3 stand as informational (I1 pinned by case 3, as the author notes). CI on the head: 25 pass, 4 skip, 0 fail; MERGEABLE.
Design unchanged from my first pass: the row is a registration-time contract, checkAgentEdge is an allowlist, the loop-id denial is the #186 shape. Ready for the next train.
Summary
run_agent_loopnever ran the agent permission edge (checkAgentAccess), and the backend behind it resolves an agent-scoped key to its owner (Invariant security: implement safe tar extraction with symlink/hardlink validation #8) — so an agent key could start a loop on any same-owner sibling with noagent_permissionsedge. Reproduced live: the loop ran on the sibling.src/mcp-server/src/access.ts::checkAgentEdge), aTOOL_ACCESS_POLICYrow for every registered tool, a registration-time assertion (server.tsrefuses a tool without a row, anenforceon a parameter the tool does not declare, or anoneon a tool whose parameters name an agent), and an enforce-wrapper applied beforewithAudit.get_loop_status,stop_loop) resolve the loop's agent, gate, then act; a denial withholds the payload behindLoop '<id>' not found or not accessible(the id was the caller's only input — naming the agent would disclose whose loop it is); a failed resolve sends no stop and names the escape hatch.chat.ts's agent/system branch now delegates to the shared gate; unknown scopes are denied before its user branch — an allowlist (feat: machine identities for admin/ops APIs — service credentials that survive enforced 2FA #2323) that closes a fall-through which promoted an unnamed agent key to the same-owner rule. Zero behaviour change for live scopes (OPERATOR_SCOPES= user/agent/system; verified every caller).baselinedrows in the table are its work list (the destructive subset —delete_agent,start_agent,stop_agent,set_agent_github_pat,export_agent_data,call_user,get_agent_auth— is named there).Changes
src/mcp-server/src/access.ts(new) —checkAgentEdge,resolveClient(moved from chat.ts),TOOL_ACCESS_POLICY(130 rows: 1 enforce, 31 in-tool, 53 baselined, 45 none),policyFor,withAgentAccesssrc/mcp-server/src/server.ts—addToolWithAuditrunspolicyFor; enforce rows wrapped;registerDynamicTooltakes a policysrc/mcp-server/src/tools/loops.ts— verified header; resolve → gate → act for the loop-id pairsrc/mcp-server/src/tools/chat.ts,tools/dynamic-agents.ts,types.ts,client.ts.github/workflows/mcp-server-test.yml— offline ESM boot smoke ofdist/server.js(bundler resolution accepts an extensionless import that Node ESM rejects at runtime)src/mcp-server/src/access.test.ts(14),src/mcp-server/src/tools/loops.test.ts(16),src/mcp-server/src/access-wiring.test.ts(3 — review C1: the registered tool over a real transport with an agent key and a counted backend),tests/journeys/test_j10_agent_calls_agent_journey.py(strict xfail removed; +1 loop-id denial test)run-agent-loop.md,agent-permissions.md,agent-to-agent-collaboration.md,mcp-agent-exposure.md,mcp-orchestration.md,architecture/mcp-server.md,requirements/scheduling.md§38.1,orchestration-invariant-catalog.mdP-02,learnings.md,docs/security-reports/cso-diff-2026-09-15-628-loop-permission-gate.*Test Plan
npm run buildclean;npm test409/409 (375 → +34)access-wiring.test.tsbootscreateServerin key mode, presents an agent-scoped key over the streamable-HTTP transport, and the stub backend countsPOST /api/agents/<target>/loops— no edge ⇒ denial and zero posts; edge ⇒ the loop starts; self ⇒ starts with no permission readstop_loop, a tool losing its row, aloop_id-only tool losing its row, gate result ignored, gate after the side effect, a stale row, unknown-scope fallthrough, unnamed agent key, payload returned on denial — 10/10 rednode --input-type=module -e "await import('./dist/server.js')"resolvesrun_agent_loopwithsuccess:falseand the byte-identicalchat_with_agentreason; J10 full file 9 passed / 2 xfailed (bug: agent-to-agent chat chains have no depth guard — a runaway A→B→A bounce is bounded only by capacity parks and hop timeouts #2806, bug(mcp): a denied chat_with_agent / fan_out call is audited as a successful tool call #2807) / 2 keyed-skips — the flipped test and the new loop-id test greentests/unitguards that scansrc/mcp-server: 864 passed/review0 critical ·/cso --diff0 introduced (report committed)Journey Impact: extends: J10
Mutation:
src/mcp-server/src/access-wiring.test.ts— withserver.ts::addToolWithAuditcomposing the wrapper and then handingtool.execute(notexecute) towithAudit(the review's mutation), the no-edge case goes red: the stub backend receivesPOST /api/agents/bravo/loopsand the caller readssuccess: true; restored byte-identical, 409/409. The ten-mutation battery on the gate, the policy table and the loop-id pair is listed under Test Plan.Fixes abilityai/trinity-enterprise#628
(Cross-repo: the keyword links but does not auto-close the private issue — closed at release. AC-02's audit is posted on the issue; the 53
baselinedrows are the same list in code.)🤖 Generated with Claude Code