Skip to content

fix(auth): an agent key reaches only itself, its edges and the agents it spawned — on the raw backend routes too (trinity-enterprise#629) - #3346

Merged
vybe merged 10 commits into
devfrom
fix/ent629-agent-reach
Oct 13, 2026
Merged

vybe merged 10 commits into
devfrom
fix/ent629-agent-reach

Conversation

@dolho

@dolho dolho commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

An agent-scoped MCP key resolves to its owner with the owner's role (Invariant #8), so every access and owner check said yes for any sibling the owner holds. On an admin-owned install, that's every agent. The agent_permissions edge the MCP layer checks (P-02) wasn't enforced one hop down. With its injected key and TRINITY_BACKEND_URL, agent A could, for any sibling B: chat with, task, fan out to, or loop it; read its history and logs; replace its git token. The public guardrails page (#3280) says an agent can call only the agents you allow. Operator ruling 2026-10-06: fix the hole.

Narrowed in the shared gate, not per endpoint (the #1890 lesson). dependencies.agent_may_reach(current_user, target, manage=):

Tier Where An agent key reaches
use get_authorized_agent[_by_name], assert_agent_access, A2A inbound itself · an agent it has an edge to · an agent it spawned with its current key
manage get_owned_agent[_by_name], assert_agent_owner itself · an agent it spawned. An edge means "may call", never "may manage".
  • Refusals outside the reach get the uniform 404 (SEC: User and Agent Enumeration via differential API responses #186). Humans, user/system keys and connectors are unchanged.
  • Capabilities (ent#164 / ent#596) earn reach per request. A capability fence (capability_fence, require_person_or_capability, the skills fence) admits a holder only after the grant check and owner_owns_agent (owner_username equality, never the admin short-circuit). It records the target on the request's principal (User._capability_reach), and agent_may_reach honours that record. So a schedules.manage holder reaches a sibling's schedule routes, an agents.manage holder its reconfigure routes, a skills.manage holder its skills routes, and none of them reaches anything else or another user's agent. Holding a grant is never reach by itself. The files tab applies the same bound per file class (skills / instructions).
  • Inline checks: every inline can_user_access_agent / can_user_share_agent call ANDs the reach (AST-guarded). nevermined's admin shortcut no longer skips it. Loops by id check reach before the admin/initiator shortcut. Credential export/import refuse an agent key by name before the owner fence (human_only).
  • Operator queue: an admin-owned agent key gets {self} ∪ edges ∪ spawned, not every agent's asks.
  • Chat history/sessions: an agent key never gets the admin "every user" view.
  • MCP: every baselined: ENT629 row (33 tools) is now enforce. The edge is decided by GET /api/agents/{caller}/reach/{target}: the edge or the spawn record, from the database, never the container list. So a ghost with no container, a child being recreated, and a Docker read fault no longer read as "not permitted".

Behaviour change to know before merging

Any agent that today reaches a sibling without an edge through the raw backend or a now-enforce MCP tool will get 404 / "Permission denied". That's the purpose of the ruling. Spawned children are unaffected. None of the public abilities skills call the backend raw. Private orchestration skills are not audited: they will need an edge for each sibling they act on.

Not in this PR

Fleet-level routes that take no single agent (collection reads such as the agent list, event subscriptions and the activity timeline, plus some fleet-level writes) still answer an agent key with its owner's reach. Some return task text and emails. They're tracked privately as a follow-up (#3346 review); security.md says so.

Test Plan

  • tests/unit/test_ent629_agent_reach.py, 41 tests on a real (temp) schema:
    • the reach truth table, and a rotated parent key losing spawn reach;
    • all path helpers and imperative helpers, A2A inbound, and the operator queue (admin- and non-admin-owned);
    • the per-request capability record, including through a real route under TestClient;
    • another user's agent on every capability path;
    • credential human_only, loops by id, the spawned-set query (soft-deleted excluded), the reach endpoint, the chat-history rule;
    • an AST guard on inline checks.
  • Mutation checks:
    • reach always true → 14 red; an edge counted as manage → red;
    • each of the review's four surviving mutations (human_only, the non-admin queue bound, deleted_at, the skills owner bound) → red.
  • Related suites (ent164, ent596, ent753, 2996, 1310, 186, 293, 2806, 2973, 3435, 3052, loops, operator queue, facade, models): 777 passed + 7 xfailed. MCP: 885/885, tsc clean.
  • CI green on the previous head, including journey-smoke (J10 loop refusal) and regression diff.

Fixes abilityai/trinity-enterprise#629

🤖 Generated with Claude Code

dolho and others added 2 commits October 7, 2026 17:13
… it spawned — on the raw backend routes too (trinity-enterprise#629)

An agent-scoped MCP key resolves to its OWNER carrying the owner's role
(Invariant #8), so every access/owner predicate said yes for any sibling the
owner holds — on an admin-owned install, every agent. The agent_permissions
edge the MCP layer checks (P-02) did not exist one hop down: with its injected
key and TRINITY_BACKEND_URL, agent A could chat with, task, loop, read the
history and logs of, or replace the git token of any sibling B. Ruling
(operator, 2026-10-06): fix the hole.

Narrowed in the shared gate, not per endpoint (the #1890 lesson):

- dependencies.agent_may_reach(current_user, target, manage=):
  * use tier — get_authorized_agent[_by_name], assert_agent_access, A2A
    inbound: itself, an edge target, an agent it spawned with its current key;
  * manage tier — get_owned_agent[_by_name], assert_agent_owner: itself or an
    agent it spawned. An edge means "may call", never "may manage".
  Outside its reach reads as the uniform 404 (#186). Every other principal is
  unchanged. The edge read fails closed, like the MCP layer's.
- get_skill_managed_agent_by_name keeps the plain owner fence: skills.manage
  (ent#596) IS the grant to manage the owner's other agents.
- Every inline can_user_access_agent / can_user_share_agent call (16 files)
  ANDs the reach in; nevermined's admin shortcut no longer skips it.
- The operator queue had its own predicate (admin role → no filter): an agent
  key now always gets a set narrowed to its reach.
- MCP: every `baselined: ENT629` row in TOOL_ACCESS_POLICY is `enforce` on its
  agent parameter; the "tool-surface gate only" notes now name the backend fence.

Collection reads (the agent list, event-subscription list, MCP key list) are
not narrowed: they disclose names, never act on an agent.

Tests: tests/unit/test_ent629_agent_reach.py — the reach truth table on a real
schema, every helper, A2A inbound, the operator queue, the skills capability
keeping its reach, the dispatch routes resolving through the narrowed gate,
and an AST guard that fails the build on an inline access check without the
reach (exemptions carry their reason). Mutation-checked: reach always-true,
edge-as-manage, the access dependency dropping it, and an inline site
dropping it each turn tests red.

Docs: Invariant #8, security.md §5, P-02, the J10 docstring, mcp-server.md
and the affected feature flows and requirements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…b; regression fixes (trinity-enterprise#629)

From the regression run over every unit file touching a changed module
(14 new failures vs dev, all traced):

- The skill fence's owner check is its own seam again,
  `get_capability_owned_agent_by_name` (not narrowed: the capability is the
  grant), so the ent#596 recorder patches a named function.
- files.py: a skills.manage holder writing a sibling's skills path reaches it
  without an edge (`_may_reach_for_path`); every other path is narrowed. The
  capability gate still decides the write.
- share_agent_file checks "an agent key shares only its own volume" before the
  (now narrowed) owner gate, so a sibling gets the specific refusal.
- The depth suites (#2806, #2973) seed the A→B edge their sibling calls need.
- The edge read fails closed (a fixture schema without agent_permissions
  refused instead of raising — the enterprise assignment suites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Local regression run

I ran 257 unit files: every file that imports or exercises a module this PR changes.

  • First pass: 7015 passed, 41 failed. I re-ran the same 41 against this PR's parent commit on dev, and 27 fail there too (ent#679 payments, ent#751 gate callers and others), so those predate this PR. The other 14 were new, and all of them are fixed in 47909a4:
New failure Cause Fix
test_ent596_skill_manager ×5 Its recorder patched get_owned_agent_by_name as the skill fence's owner check. The owner check that a capability dependency uses is its own named function again, get_capability_owned_agent_by_name, and it is not narrowed. The test now patches that name.
the same, files-tab case A skills.manage holder editing a sibling's skills through the files tab was refused, because there's no edge. files._may_reach_for_path: a holder reaches a sibling's skills path only. Every other path is narrowed. A test is added for it.
test_1310…anti_exfil_sibling Still a 403, but the narrowed owner gate fired before the specific message. share_agent_file checks "an agent key shares only its own volume" first.
test_2806 ×4, test_2973 ×3 Agent A calls sibling B with no edge, which is exactly what is now refused. Their fixtures seed the A→B edge, as a real deployment needs.
test_ent549 ×1 Flaky: I'd mounted the enterprise module partway through the run. Passes on re-run.

After the fixes: every affected file passes (ent#596, ent#629, ent#549, #1310, #2806, #2973: 235 passed). The enterprise assignment suites pass too (112). The edge read fails closed, so a fixture schema with no agent_permissions table gets a refusal instead of an exception.

🤖 Generated with Claude Code

@vybe

vybe commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

merge-train: ejected from this batch — rides the next train once fixed. It conflicts with dev in src/backend/routers/agents.py, src/backend/routers/schedules.py, src/backend/services/agent_service/files.py (and docs/memory/architecture/security.md) — most likely #3236 (ent#164) touching the same handlers. That's a re-apply over someone else's change, so it needs the author rather than a mechanical merge.

@vybe vybe added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 7, 2026
Conflicts with #3236 (ent#164) and #3208/#753 (skill gates): imports and
the MCP access table keep both sides; files.py keeps both helpers.

Semantic fix: ent#629's narrowed owner gate refused what ent#164 and
ent#753 grant — an agents.manage holder reconfiguring its owner's other
agents, and a skills.manage holder setting a sibling's skill gate (7
tests red after the textual merge). A capability whose grant IS the
reach keeps it, bounded to agents the owner OWNS:
- agent_may_reach(manage=True) admits an agents.manage holder on an
  owner-owned target (_manages_owners_agent, fail-closed); reads are not
  widened.
- The skill-gate writes resolve the agent with get_capability_owned_agent,
  the path-dependency form of the capability owner fence, after
  require_person_or_capability has refused a non-holder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Merged dev in c0356f0ba. This answers the merge-train ejection.

Textual conflicts, with both sides kept: the dependencies imports in agents.py and schedules.py, files.py (ours _may_reach_for_path, dev's _touches_instructions), the access.ts table (get_agent_skills → enforce, plus dev's three skill-gate rows), and security.md.

Semantic conflict. The textual merge left 7 tests red. ent#629's narrowed owner gate refused two things that #3236 and #3208 grant:

  • test_2996_owner_config_person_only::test_an_agent_holding_agents_manage_changes_the_stored_value ×5: an agents.manage holder reconfiguring its owner's sibling.
  • test_ent753_gate_routes ×2: a skills.manage holder setting a sibling's skill gate.

Fix: a capability whose grant is the reach keeps that reach, but only to agents its owner owns (owner_username equality, never the admin short-circuit). That is the same bound _refuse_unless_owners_agent applies:

  • agent_may_reach(manage=True) now admits an agents.manage holder on an owner-owned target (_manages_owners_agent, fail-closed). Reads are not widened.
  • The skill-gate writes resolve the agent with get_capability_owned_agent, the path-dependency form of the capability owner fence. It runs after require_person_or_capability has already refused a non-holder.
  • security.md gets one line stating the rule.

Local: ent164, ent629, ent596, 2996 ×2, ent753 routes, 1310 ×2, 2806, 186 and 293 give 449 passed. MCP access.test.ts + access-wiring.test.ts: 33 passed.

🤖 Generated with Claude Code

@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 8, 2026
…pawned, read directly (trinity-enterprise#629)

dev's ent#611/#715/#815 queue suites (29 tests) went red after the merge:
an admin-owned agent key's scope was built from the owner's
accessible-agent list and then filtered agent by agent, so a self or
permitted agent without an ownership row (every one in those fixtures)
dropped out. The scope now starts from {self} ∪ the agent's edges — the
MCP's own rule — bounded by a non-admin owner's set, plus the owner's
agents it spawned. The edge read fails closed. The router also gets the
logger it was missing.

ent#715's heads-ups fixture seeds the agent key's edge to the agent it
reads, so the test still checks that person rows are withheld, not that
the whole queue is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe

vybe commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

merge-train: ejected from this batch — rides the next train once fixed.

The narrowed reach removes cross-agent reach from capabilities that dev grants for exactly that. agent_may_reach (src/backend/dependencies.py:1358) admits a non-self, non-spawned target on the manage tier only via _manages_owners_agent, which honours agents.manage alone — and on the use tier, only via an edge. Verified against a real temp schema (admin-owned holder, sibling with no edge):

  • schedules.manage holder → schedule create (routers/schedules.py:218, assert_agent_access) 403; enable/disable/trigger (schedules.py:473/496/519, OwnedAgent) 404. db/capability_grants.py documents schedules.manage as what an agent needs "to change ANOTHER agent's schedules".
  • instructions.manage holder → git reset-to-main (routers/git.py:779) and instruction-file writes (services/agent_service/files.py:475/552) 404.
  • agents.manage holder → PUT /{name}/model (routers/chat.py:767, use tier) 404, though agents.manage explicitly covers models.
  • Edge holders can create a sibling's schedule but not enable it.

CI is green because no test drives these holders against a sibling. The fix is a design call — how a passed capability fence tells the reach gate to stand down (agent_may_reach never sees which capability the route checked; widening _manages_owners_agent to any capability would be wrong) — so it's yours.

Mechanical, same push:

  • routers/loops.py:120-122 _check_loop_access returns early on role == "admin" / started_by_user_id == user.id before any reach check — an agent key reads/stops a sibling's loop by id with no edge (every sibling's loop is "started by" the shared owner). Same class as the nevermined fix in this PR; check agent_may_reach(user, loop["agent_name"]) first.
  • files.py:157-168 _may_reach_for_path gives a skills.manage holder any agent the owner can access (other users' agents on an admin-owned install), while the new security.md line says "agents its owner OWNS". Pre-existing on dev; fix the code or the doc.
  • operator_queue.py:98 does ~2 lookups per held agent on every queue call by an agent key; one "children spawned by my current key" query would do.

Lane C also wants frontend-e2e green on the PR (skipped — no ui label). Everything else held up: heartbeat / result callback / reports / ghost spawning / fan-out / A2A / trinity-system unaffected; 547 tests pass on the PR merged with dev.

@vybe

vybe commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-10): ejected — the branch has not moved since the 2026-10-08 ejection and both criticals reproduce on the merged tree: (1) dependencies.py:1346 agent_may_reach / :1363 _manages_owners_agent revoke reach that ent#164 capabilities grant — a schedules.manage holder gets 404 from get_owned_agent (schedules.py:473/496) and 403 from assert_agent_access (schedules.py:218) on a sibling; an agents.manage holder gets 404 on PUT /{name}/model (chat.py:767); (2) routers/loops.py:118 _check_loop_access returns on role == "admin" before any reach check, so an admin-owned agent key reads/stops any sibling's loop by id. Rides the next train once fixed.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

dolho and others added 2 commits October 12, 2026 10:33
# Conflicts:
#	docs/memory/architecture.md
#	src/backend/routers/agents.py
#	src/mcp-server/src/access.ts
…ity-enterprise#629)

Merge-train review (2026-10-08, 2026-10-10): the narrowed gate revoked the
cross-agent reach that ent#164 capabilities exist to grant. A
`schedules.manage` holder got 404/403 on a sibling's schedule routes, an
`instructions.manage` holder on its instruction files and git reset, and an
`agents.manage` holder on `PUT /{name}/model`, because `agent_may_reach`
never saw which capability the route had checked.

- The two capability fences (`capability_fence`,
  `require_person_or_capability`) record the target they admitted on the
  request's principal (`User._capability_reach`, a private attribute), after
  the grant check and the owner bound. `agent_may_reach` honours that record
  on both tiers. This replaces `_manages_owners_agent`, which widened every
  owner check for an `agents.manage` holder on every route.
- `files._may_reach_for_path`: the path exception covers `instructions.manage`
  as well as `skills.manage`, needs the grant for each file class the path
  touches, and is bounded to agents the owner OWNS (`owner_owns_agent`). It
  used to reach any agent the owner could access.
- `loops._check_loop_access`: reach is checked before the admin and
  initiator shortcuts, so an agent key cannot read or stop a sibling's loop
  by id.
- Operator queue: the spawned set is one query
  (`list_agents_spawned_by_key`), not two lookups per held agent.

Also merges dev: the agents.py activity routes stay deleted (#3483), and
access.ts takes dev's owner constants alongside this PR's enforce rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 12, 2026
@dolho

dolho commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Answering the 2026-10-08 and 2026-10-10 ejections. dev merged (e6bfacd3b), fixes in 89eb4339e.

Critical 1: capabilities lost their reach. I went with a per-request record instead of widening the gate. capability_fence and require_person_or_capability now record the target they admitted on the request's principal (User._capability_reach, a private attribute), after the grant check and the owner bound (owner_owns_agent: owner_username equality, never the admin short-circuit). agent_may_reach honours that record on both tiers. So:

  • schedules.manage holder → a sibling's create (assert_agent_access) and enable/disable (OwnedAgent) pass;
  • instructions.manage holder → git/reset-to-main-preserve-state passes;
  • agents.manage holder → PUT /{name}/model and the agent_config reconfigure routes pass;
  • the reach is for that target on that request only. Holding a grant is never reach by itself, so _manages_owners_agent (which widened every owner check for an agents.manage holder, on every route) is gone.

Manual schedule trigger has no capability fence on dev, so it stays narrowed to self and spawned. If it should count as schedules.manage, that means adding the fence there, which is a separate call.

Critical 2: _check_loop_access. It now checks reach before the admin and initiator shortcuts.

Mechanical items:

  • files._may_reach_for_path is bounded to agents the owner OWNS (code fixed to match the doc). It also covers instructions.manage now, needing the grant for each file class a path touches. That was the instruction-file 404 at files.py:475/552.
  • Operator queue: the spawned set is one query (db.list_agents_spawned_by_key), using the same key-id rule as the gate.
  • Merge: the agents.py activity routes stay deleted (refactor(api): remove nine uncalled routes and the orphan observability store (#3434) #3483). access.ts takes dev's CREDENTIAL_HUMAN_ONLY / KEY_ADMIN_ONLY plus this PR's two enforce rows. The live /api/activities/timeline is a list read, so it's in the existing list-read follow-up.

Tests: 8 new in test_ent629_agent_reach.py:

  • fenced holder → sibling reachable on both tiers;
  • same target only, same request only;
  • agents.manage alone is no longer reach;
  • require_person_or_capability records;
  • a fence never admits another user's agent;
  • a refused non-holder records nothing;
  • the loop-by-id refusal;
  • the owner bound on the file path for both classes;
  • the spawned-set query.

Mutation check: dropping the record in capability_fence turns the first test red. The ent#596 files fixture now seeds the owner row a real deployment has.

Locally, 950 passed and 7 xfailed across ent164, ent629, ent596, 2996 ×4, ent753 routes, 1310 ×2, 186, 293, 2806, 2973, operator-queue, loops, file-path, facade-parity and models-centralized. MCP access + access-wiring: 36 passed, tsc clean.

🤖 Generated with Claude Code

dolho and others added 2 commits October 12, 2026 11:03
…sal (trinity-enterprise#629)

J10 `test_without_permission_an_agent_cannot_read_or_stop_a_loop_on_another`
failed on journey-smoke. Since the backend checks an agent key's reach before
the admin shortcut in `_check_loop_access`, `GET /api/loops/{id}` answers 403
for a loop on an agent outside the caller's reach. `get_loop_status` and
`stop_loop` resolve the loop through that read before their own gate, so the
caller got a raw `API error (403)` instead of "Loop '<id>' not found or not
accessible" and the hint.

A 403 on the read now returns the same uniform body as the MCP gate, with the
same log line and audit row. The stop is still never sent. A 404 (no such
loop) keeps the backend's own shape, as the existing test pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… owner fence (trinity-enterprise#629)

The backend regression diff caught 4 new failures in
`test_3435_credential_tool_gates::test_agent_key_is_refused_human_only_even_for_its_owner`.
With the narrowed owner fence, `get_owned_agent_by_name` answers an agent key
on a sibling with the uniform 404, so the handler-body
`reject_agent_principal` never ran. The agent was told "Agent not found"
instead of the rule it hit.

`dependencies.human_only` is the route-level form of `reject_agent_principal`.
On export and import it now runs before the ownership dependency. The refusal
depends on the principal alone, before any lookup, so it discloses nothing
about the target (#186). The inline call stays as belt-and-braces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer

Copy link
Copy Markdown
Contributor

@dolho, I reviewed f9cd83859 as part of 1.0 release testing (trinity-enterprise#847, the agent-reach row).

Verdict: it does what trinity-enterprise#629 asks. The six per-agent reads my release probe made with a sibling's key now all go through the narrowed checks: agent detail, schedules, files, skills, and capability grants on two agents. So do the sibling file downloads. Nothing here blocks the merge. There are three things I'd fix first, some test gaps, a design question, and the description to refresh.

What I ran

  • The unit tests around this change: ent629, ent596, ent164, ent753 routes, 2996 ×5, 1310 ×2, 186, 293, 2806, 2973, ent715, loops, 2094 ×2 and the 837 floor. 739 passed. The two test_2996_route_census_runtime errors come from my local venv, which lacks opentelemetry; they pass on CI.
  • MCP access, access-wiring and loops tests: 54/54. tsc is clean.
  • 17 hand mutations: 13 caught. Making agent_may_reach always return True turns 14 tests red, so the fix has a real regression test. The 4 that survive are in point 4.

1. skills.manage still reaches another user's agent on an admin-owned install

  • The path: get_skill_managed_agent_by_name (dependencies.py:2446) calls get_capability_owned_agent_by_name, which uses narrow=False and so falls back to can_user_share_agent. That returns True for any agent when the owner is admin (db/agent_settings/sharing.py:304).
  • I ran a probe on this head with an admin-owned agent key holding skills.manage:
    • get_skill_managed_agent_by_name(<another user's agent>) returns that agent;
    • can_manage_agent_skills (:2269) returns True;
    • files._may_reach_for_path refuses the same target.
  • So one capability has two bounds. The dependency's docstring says the grant "never widens reach beyond the owner's own agents", and security.md says the fences record only after owner_owns_agent.
  • vybe raised the same point about files.py on 10-08, and it was fixed there only. The gap predates this PR (dev went through get_owned_agent_by_name), but this PR is where the bound is now defined.
  • Suggested fix:
    • In get_skill_managed_agent_by_name and can_manage_agent_skills, for an agent principal, call _refuse_unless_owners_agent and then _admit_capability_reach after the grant check, and return the narrowed get_owned_agent_by_name.
    • The skill-gate writes should then pass with get_owned_agent_by_name as well, because _gate_writer resolves first and records the target.
    • That would let get_capability_owned_agent[_by_name] and _owned_agent_or_404(narrow=False) go: one mechanism, and no unnarrowed path dependency left for someone to reuse.
    • A FOREIGN case next to test_a_fence_never_admits_another_users_agent would pin it.

2. MCP enforce reads a Docker-built list, so a target with no container reads as "not permitted" (traced in code, not run)

  • checkAgentEdge (access.ts:162) calls isAgentPermitted, which reads the lenient GET /api/agents/{caller}/permissions.
    • The backend builds permitted_agents from get_accessible_agents(), which comes from list_all_agents_fast() (services/agent_service/permissions.py:50).
    • getPermittedAgents (client.ts:984) turns any error into [].
    • Stopped containers are listed; missing ones are not.
  • Where it bites:
    • A parent can't delete_agent its own crash-residue ghost (routers/agents.py:744: "must be force-discardable, never 404").
    • It can't start_agent a recovered child (lifecycle.py:394-403 recreates the container).
    • Both tools were baselined before this PR and worked.
  • The same path also refuses:
    • a spawned child whose edge was removed, although the backend admits it through spawn provenance (dependencies.py:1547);
    • every one of the 33 rows during a Docker read fault, with "Permission denied … Configure permissions in the Trinity UI".
  • Suggested fix: decide the MCP edge from the database (the edge plus the spawn record, i.e. what agent_may_reach answers), not from the container-filtered list.

3. The new security.md line understates what is still open
security.md:59 says: "Collection reads (the agent list, the event-subscription list) are not narrowed: they disclose names, never act."

  • Outside this diff, I found fleet-level routes where an agent key still reaches its owner's other agents. Two of them write. Some of the reads return task text and requester emails, not just names.
  • I'll file these privately as a follow-up and link it here.
  • For this PR: please reword that line so it doesn't promise more than the code holds.

4. Test gaps (mutations that survive)

  • Removing dependencies=[Depends(human_only)] from credentials export or import (routers/credentials.py:457, :532) leaves everything green. Only the refusal message changes, since the narrowed owner fence still returns 404 for a sibling, but the last commit has no test.
  • Removing reach &= owner_names | {me} (routers/operator_queue.py:96) stays green. The fixture only uses an admin owner, so the non-admin bound is untested.
  • Removing deleted_at.is_(None) from list_agents_spawned_by_key (db/agents.py:470) stays green.
  • No real-route test sends a capability holder through a capability_fence route (schedules, model, git reset) to a sibling; the fence is only called directly.
    • The per-request record relies on FastAPI handing the same User object to the fence and to the path dependency.
    • One TestClient case would pin that, e.g. a schedules.manage holder enabling a sibling's schedule.

5. Design question: an edge also lets an agent read everyone's chats with the target

  • routers/chat.py:829 and :867 drop the per-user filter for role == "admin". So an admin-owned agent key with an edge to B reads every user's history and sessions with B.
  • Before this PR that applied to any sibling; now it applies to edge targets only.
  • If "may call" shouldn't include "may read everyone's conversations with it", those two lines need the same agent_name check _accessible_set got.

6. PR description

Learnings fragment for the "bound fixed on one of two paths" class. Suggested file: docs/memory/learnings/2026-10-12-capability-bound-one-helper.md:

## 2026-10-12 — pitfall — a capability's owner bound must live in ONE helper, not in each path that honours the grant
**Context**: abilityai/trinity#3346 (trinity-enterprise#629 × #596/#164). `skills.manage` is honoured on two paths: the skills routes (`dependencies.get_skill_managed_agent_by_name`) and the files tab (`files._may_reach_for_path`). A review asked for the owner bound on the files path; it was added there only, so on an admin-owned install the skills routes still admitted another user's agent — `can_user_share_agent` short-circuits True for `role == "admin"`, and an agent key carries its owner's role.
**Lesson**: when a grant is honoured on more than one path, put its bound (grant check → `owner_owns_agent` → record) in one helper every path calls, and test each path with an agent owned by ANOTHER user under an admin owner. `can_user_share_agent` / `can_user_access_agent` are never an owner bound for an agent principal.

Minor: agent_may_reach costs 2–3 queries per call for an agent key, so per-agent loops (autonomy.py:181, evaluations._operator_for) cost N×3 queries for agent keys. Fine as is.

… the #3346 review (trinity-enterprise#629)

Addresses webmixgamer's review of f9cd838.

1. `skills.manage` reached another user's agent through the skills routes on
   an admin-owned install. `get_skill_managed_agent_by_name` went through the
   unnarrowed owner fence, which `can_user_share_agent` answers True for any
   agent when the owner is admin; the files tab refused the same target. The
   skills fence now applies the bound every capability fence applies (grant,
   then `owner_owns_agent`, then record the reach) and returns the narrowed
   `get_owned_agent_by_name`. `can_manage_agent_skills` takes the same bound.
   The skill-gate writes use `get_owned_agent_by_name` (`_gate_writer`
   records first), so `get_capability_owned_agent[_by_name]` and
   `_owned_agent_or_404(narrow=False)` are gone: one mechanism.
2. The MCP `enforce` gate read the lenient permissions list, which is built
   from the Docker-filtered fleet. A ghost with no container, a child being
   recreated, a spawned child whose edge was removed, and every call during a
   Docker fault all read as "not permitted". New
   `GET /api/agents/{caller}/reach/{target}` answers from the edge or the
   spawn record in the database; `TrinityClient.isAgentPermitted` reads it
   and fails closed.
3. security.md no longer says collection reads "disclose names, never act":
   fleet-level routes still answer an agent key with its owner's reach, some
   of them write, and some return task text and emails. A private follow-up
   tracks them.
4. Tests for the review's surviving mutations:
   - credential export's `human_only`;
   - the queue's non-admin bound;
   - `deleted_at` in the spawned set;
   - a schedules holder through the real route via TestClient (same principal
     object for the fence and the path dependency);
   - another user's agent on the skills fence;
   - the reach endpoint.
   Each mutation now fails a test.
5. An agent key never gets the admin "every user" chat history or sessions
   view: "may call" is not "may read everyone's conversations with it".

Also: learnings fragment (capability bound in one helper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@webmixgamer thanks, all six are addressed in 5af5897d0.

  1. skills.manage and another user's agent. Fixed as you suggested:
    • get_skill_managed_agent_by_name does grant, then _refuse_unless_owners_agent, then _admit_capability_reach, and returns the narrowed get_owned_agent_by_name. can_manage_agent_skills takes the same owner_owns_agent bound.
    • The skill-gate writes use get_owned_agent_by_name, because _gate_writer records first.
    • get_capability_owned_agent[_by_name] and _owned_agent_or_404(narrow=False) are deleted: one mechanism.
    • New FOREIGN cases are in ent629 and ent596.
  2. MCP edge from a Docker-built list. New GET /api/agents/{caller}/reach/{target} decides from the edge or the spawn record in the database, with no container read. TrinityClient.isAgentPermitted reads it and fails closed. A ghost with no container, a recreated child, an edge-less spawned child and a Docker fault no longer read as "not permitted". It's in the route census and the endpoint catalog.
  3. security.md line. Reworded. It now says fleet-level routes still answer with the owner's reach, some write, and some return task text and emails, and that a follow-up tracks them. Thanks for filing that privately.
  4. Surviving mutations. Tests added for credential human_only (agent key on a sibling → named 403), the queue's non-admin bound, deleted_at in the spawned set, and a schedules.manage holder enabling a sibling's schedule through the real route under TestClient, which pins the same-principal-object assumption. I re-ran your four mutations; each now goes red.
  5. Edge → everyone's chats. I took the narrow reading. An agent key never gets the admin "every user" view on /chat/history/persistent or /chat/sessions, so it reads only its own user's conversations with the target.
  6. Description refreshed: per-request record, 41 tests, test plan ticked.

I added the learnings fragment as you proposed (docs/memory/learnings/2026-10-12-capability-bound-one-helper.md).

Locally: ent629 41/41; related suites 777 passed + 7 xfailed; MCP 885/885, tsc clean.

🤖 Generated with Claude Code

@dolho
dolho requested a review from webmixgamer October 12, 2026 11:44
@webmixgamer

Copy link
Copy Markdown
Contributor

@dolho thanks, I checked 5af5897d0:

  • Backend: 759 passed. MCP: 121/121, tsc clean.
  • My skills probe now refuses another user's agent, on both the skills routes and the files path.
  • The mutations for credential export's human_only, the queue's non-admin bound and deleted_at now each go red.
  • Dropping the fence's reach record turns 2 tests red, one of them through the real route.

Three things left, the first one red on CI:

  1. regression diff fails on this head with one new failure.
    • The test is test_ent754_gate_map_viewer_and_hook::test_probe_is_a_persons_even_for_a_key_that_may_manage_skills[agent_key_with_skills_manage].
    • Its precondition at :276, can_manage_agent_skills(principal, AGENT) is True, is now False. The new owner bound reads db.get_agent_owner, and that fixture only stubs can_user_share_agent (:117).
    • The product behaviour is right; it's the same fixture gap the ent596 files fixture had.
    • I checked locally: as is, 1 failed and 29 passed. With monkeypatch.setattr(db, "get_agent_owner", lambda name: {"owner_username": "owner"} if name == AGENT else None) added to the fixture, all 30 pass.
  2. isAgentPermitted swallows the Workspace-only refusal.
    • client.ts:1017 now ends in catch { return false; }, so a workspace_only 403 reads as "not permitted".
    • Its siblings getPermittedAgents and getAgentAccessInfo rethrow it, and access.test.ts:229 pins that.
    • A probe on this head: isAgentPermitted resolves false where getPermittedAgents rejects with workspace_only.
    • So an agent whose owner is a Workspace-only member is told "Permission denied … Configure permissions in the Trinity UI" instead of the account's real situation.
    • Fix: catch (err) { if (isWorkspaceOnlyRefusal(err)) throw err; return false; }, plus a third case in that describe.
  3. Credential import's human_only is still unpinned. Dropping it from routers/credentials.py:532 stays green. The new test covers export only.

Optional: /reach/{target} answers true for an edge to an agent the owner can't access, where the old list only held agents the owner could access. The backend still refuses those calls, so only the message differs. Adding and db.can_user_access_agent(current_user.username, target) would keep the old answer.

I'll approve once CI is green. The fleet-level follow-up is filed privately as trinity-enterprise#872.

@webmixgamer webmixgamer added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 12, 2026
…inity-enterprise#629)

The backend regression diff caught
`test_probe_is_a_persons_even_for_a_key_that_may_manage_skills[agent_key_with_skills_manage]`.
Since the #3346 review, an agent key's `skills.manage` reaches only agents its
owner OWNS (`owner_owns_agent`), not every agent `can_user_share_agent`
admits. The fixture stubbed "may share" but never said who owns
`gated-agent`, so the bound read "unknown owner" and refused. The test's
premise is unchanged: the key's owner owns the agent, so the manage check
admits it and the probe is still refused for a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 12, 2026

@webmixgamer webmixgamer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving 2cc17964e: CI is green and the ent754 fixture is fixed. Points 2 and 3 from my last comment are still open: isAgentPermitted drops the workspace_only refusal, and the credential-import guard has no test like the export one. Both are small, and fine as a quick follow-up.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train/20261012-2333 (#3639)

@vybe
vybe merged commit 80c213f into dev Oct 13, 2026
28 checks passed
vybe pushed a commit that referenced this pull request Oct 13, 2026
…eraction with #3346)

#3346 (trinity-enterprise#629) bounds an agent key to its own agent, its
permission edges and the agents it spawned. The read-tier tests here use a
'sibling' agent key against 'shared-agent' with only the access layer
stubbed, so seven of them hit the new reach refusal before the owner-tier
gate they test. Stub reach alongside access; reach refusal on the file
routes stays pinned by test_ent629_agent_reach.py.

Mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Oct 13, 2026
… follow a link; runtime config files join the write deny lists (#3304)

* docs(files): credential files are owner-tier to read; reads never follow a link; runtime config files join the write lists

Requirements (content-files §13.1, GUARD-002 §28) and the file-routes
bullet in the architecture area file, ahead of the code.

Refs Abilityai/trinity-enterprise#819
Refs Abilityai/trinity-enterprise#823

* fix(files): credential files are owner-tier to read on download and preview

One credential constant feeds the write list and a new owner-tier read
set: the credential paths plus .git/config, the /proc and /dev spellings,
the Trinity-managed copies (.trinity/git-credential, .trinity/backup/*) and
the secret file classes (.kube/config, .config/gcloud/*, *.key, *.pem,
*.p12, *.pfx). Download and preview refuse an owner-tier path unless the
caller is a person who passes the owner tier (owner or admin); the PERSON
gate runs first. A NUL byte is refused with 400 before the agent is
called, and the agent is sent the normalised path that was checked.

The 403 body says what a teammate can still do and whom to ask.

Refs Abilityai/trinity-enterprise#819

* fix(files): audit owner-tier read decisions

Every refused owner-tier read writes one file_read_refused row, and every
allowed one by anyone other than the owner's own signed-in session writes
file_read_allowed (AUTHORIZATION, routed endpoint, normalised path, rule).
A person is filed as actor_user; an agent key as its agent; other keys by
scope and key, never as the owner. The audit is best-effort: a failure
never changes the answer.

Refs Abilityai/trinity-enterprise#819

* fix(files): runtime config files join the credential constant

~/.claude.json, ~/.claude/.credentials.json, ~/.gemini/settings.json and
~/.tmp/codex/* (the Codex runtime's login and MCP config) are refused on
write, mkdir and delete at the backend and are owner-tier to read.
Deleting .tmp or .tmp/codex through the Files routes is refused, since
they hold the Codex login.

Refs Abilityai/trinity-enterprise#823

* fix(base-image): the file guardrail denies the runtime config files

path_deny gains ~/.claude.json, ~/.claude/.credentials.json,
~/.gemini/settings.json and ~/.tmp/codex/*, and the image-build smoke
gains a ~/.claude.json row, so the build proves the baseline.

Refs Abilityai/trinity-enterprise#823

* fix(base-image): the agent server never writes runtime config through its file routes

PROTECTED_PATHS and EDIT_PROTECTED_PATHS gain .claude.json and
.credentials.json. PUT, mkdir and DELETE also refuse any resolved target
inside the runtime config paths (Claude Code's two login files, Gemini's
settings.json, Codex's .tmp/codex), so a link elsewhere in the home that
points at one is refused too; DELETE refuses a directory above one. The
home is a module constant so the handlers run over a temporary home in
tests. Snapshot refreshed.

Refs Abilityai/trinity-enterprise#823

* fix(files): file reads never follow a link

The agent server opens a download or preview path one component at a
time with O_NOFOLLOW and serves the descriptor it opened, so the checked
file is the file read. A path that is a link, or passes through one, is
refused with 403 resolved_path_mismatch for every caller, the owner and
the platform's own reads included. The backend turns that refusal into a
file_read_refused audit row and a structured 403; a platform read that
meets one logs a warning naming the file. The fence is resolved-path
containment, not a string prefix.

A source scan pins that Trinity itself creates no links in an agent home.

Refs Abilityai/trinity-enterprise#819

* fix(files): agents not yet on the current image serve no below-owner reads

The backend probes each agent container once, per container and image,
for the agent server's no-link read (ent#708-style: grep its source for
the read function). Until an agent is stopped and started on the current
image, download and preview by anyone below the owner tier are refused
with 403 agent_restart_required; the owner and admins are unaffected and
never trigger the probe. A missing target fails safe and is remembered for
that image; an exec that raises or times out fails safe and is probed
again on the next read. Each refusal is logged with a running count.

Refs Abilityai/trinity-enterprise#819

* fix(frontend): a refused file read shows the server's message

The download and preview store calls parse a text or blob error body back
into {detail, code, path}, so a structured refusal reads as its message
instead of "Request failed with status code 403". The Credentials panel
reads first and opens the editor only on content or a 404; any other
failure keeps it closed and shows the message in an InlineError beside
the file list. The Files panel's Download shows the message too.

Refs Abilityai/trinity-enterprise#819

* docs(files): feature flows and user docs for owner-tier credential reads, no-link reads and runtime config files

file-browser (owner-tier set, link refusal, older-image refusal, audit,
error bodies and what the UI shows), agent-guardrails (path_deny + 4),
agent-sharing and credential-injection, the index; user docs say what a
teammate on a shared agent can and cannot do and whom to ask, that the
Files tab does not open links, and that the runtime login files and .tmp
are not editable there.

Refs Abilityai/trinity-enterprise#819
Refs Abilityai/trinity-enterprise#823

* fix(work): the pipeline reader names a linked file in the log

The Work card reads pipeline files over its own httpx client, so the
platform-read warning in agent_client.read_file never reached it. A 403 body
is now read under the same byte budget and passed to that warning.

Refs Abilityai/trinity-enterprise#819

* fix(files): the old-image refusal asks for a restart to apply an update

The 403 agent_restart_required message now reads: "This agent needs a
restart to apply an update. Ask the agent's owner or an admin to stop and
start it in Trinity." It is shown on agents not yet on the current image.

Refs Abilityai/trinity-enterprise#819

* fix(files): only a person at the owner tier skips the older-image check

The older-image check on download and preview now exempts the same caller
the credential tier serves: a person who passes the owner gate (the
agent's owner, or an admin). Agent keys, the owner's own included, are
refused with 403 agent_restart_required like any caller below the owner
tier until the agent restarts on the current image; on a verified image
they read as before.

Refs Abilityai/trinity-enterprise#819

* fix(files): the older-image check caches only grep's own answer

The read-policy probe caches a verdict only when grep answered: exit 0
(verified), or exit 1 or 2 with no output (token or file absent; grep -qs
prints nothing). The exec helper reports a Docker fault as exit 1 with
text in its output, so that, a timeout or any other code now reads as
"not verified" for this read, is logged, and is probed again next time.

Refs Abilityai/trinity-enterprise#819

* test(files): the older-image probe targets the shipped read function

A guard reads the probe's path and token from the backend module, maps the
path through the base image's COPY of ./agent_server to the repo file, and
asserts that file defines the token as a module-level function (matched on
the def with comments stripped). Renaming or moving the read function now
fails here instead of on every agent after the next image build.

Refs Abilityai/trinity-enterprise#819

* fix(files): the objective join names a linked file in the log

The objective join reads template.yaml and objectives over the agent door
directly, so the platform-read warning in agent_client.read_file never
reached it. A 403 from that read now goes through the same warning, which
names the file when the agent server refused it as a link. The read still
reports the file as unreadable.

Refs Abilityai/trinity-enterprise#819

* fix(base-image): download and preview read at most the size they opened

Download reads at most the file's size when it was opened, and the preview
stream stops at the Content-Length it declares. A file that grows after
the open (an agent log, say) is served at its opened size, so the body
never runs past the declared length.

Refs Abilityai/trinity-enterprise#819

* test(frontend): Quick Inject shows a refused read's message and writes nothing

Quick Inject reads the current .env before merging. A coded 403 on that
read now has a mounted case: the server's message is shown and nothing is
injected; an uncoded failure keeps the generic message.

Refs Abilityai/trinity-enterprise#819

* test(files): the link-creation scan reads a file that does not tokenize

A Python file that fails to tokenize, or is not UTF-8, was skipped by the
scan for link creation in an agent's home. It is now scanned as raw text,
comments included, so it can only add a finding, never hide one.

Refs Abilityai/trinity-enterprise#819

* docs(files): the file-route docs state the read rule and the per-verb tier

The requirements, architecture and feature-flow lines on the file routes
now state the rule: reading credential paths and the secret file classes
is owner-tier, the credential, alias and Trinity-copy patterns are on the
write deny list, and the per-verb tier follows separately.

Refs Abilityai/trinity-enterprise#819

* test(files): register the owner-tier read, no-link read and runtime config test files

Registry entries for the four new test files and the two extended ones.

Refs Abilityai/trinity-enterprise#819
Refs Abilityai/trinity-enterprise#823

* docs(files): feature flows follow the older-image check and the no-link read

The file-browser flow describes the PERSON gate before the owner tier, the
older-image check (only a person who is the owner or an admin skips it;
only grep's quiet answer is cached), the descriptor-based agent-server
reads that stop at the opened size, the three platform readers that name a
linked file, and the current line references. The sharing and credential
flows match the panel and the restart message.

Refs Abilityai/trinity-enterprise#819

* docs(files): the docs state who skips the older-image check and how a teammate gets a restart

The architecture bullet and requirements now say a conclusive older-image
probe is cached per container and image, and that only a person who is the
owner or an admin skips the check (agent keys included in the refusal).
Requirements state reads stop at the size the file had when opened.
The Files user doc quotes the restart message and lists every owner-tier
path; the sharing doc says whom to ask for a restart and that credential
management is for the owner or an admin.

Refs Abilityai/trinity-enterprise#819
Refs Abilityai/trinity-enterprise#823

* test(files): the real routes forward the checked path, survive an audit failure and probe agent keys

Route-level tests over routers.agent_files.router for download and preview:
the agent is sent the normalized path, a failing audit store changes no
answer (403 for a shared user, 200 for an admin), and the owner's
agent-scoped key gets agent_restart_required on an unverified image.

Refs Abilityai/trinity-enterprise#819

* merge-train: the owner-tier fixture grants the sibling key reach (interaction with #3346)

#3346 (trinity-enterprise#629) bounds an agent key to its own agent, its
permission edges and the agents it spawned. The read-tier tests here use a
'sibling' agent key against 'shared-agent' with only the access layer
stubbed, so seven of them hit the new reach refusal before the owner-tier
gate they test. Stub reach alongside access; reach refusal on the file
routes stays pinned by test_ent629_agent_reach.py.

Mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.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.

3 participants