feat(rbac): bound what an agent may DO by whoever invoked it

Closes 177. An agent carries a project's prompts and its MCP tools, and the only
check on a chat turn was run:agents:<name> - so anyone who could talk to an
agent inherited tool access to every project that agent had joined. Membership
was the capability. AgentProject widened it from one pinned project to many.

The rule now: knowledge travels with the agent, action is bounded by the
authority of the run. Prompts and skills still load regardless of who is asking -
a QA reviewer that forgets the project's conventions when a different person
asks is not a reviewer. Every MCP tool call is gated.

Authority is resolved once per run (services/run-authority.ts) and is per-RUN,
not per-agent: the same agent answers a human under that human's authority and,
once a scheduler exists, runs unattended under its own. `caller` intersects the
agent's memberships with the caller's run:projects - reusing the exact grant the
project-chat route already demands, rather than inventing a parallel permission.
`agent` takes every membership without consulting a user. Both are implemented;
`service-account` throws 501 rather than honouring a forgeable x-service-account
header, and task 179 records what it needs first.

Gated in BOTH listTools and callTool, deliberately: withholding a tool from the
list is presentation, not enforcement - a model can invent a tool name it was
never shown, and callTool is what would run it.

The accepted consequence is stated plainly rather than papered over: a user can
ask about a project they cannot reach and the agent will answer from its
knowledge. The system block tells the model when it is holding such context so a
persona can apply it without reciting it. That is guidance to a model, not a
boundary - the boundary is that the tools are absent.

Two sibling holes found while exploring and closed here:

- Thread hijack. resolveThreadId on the agent path had no owner check, so anyone
  with run:agents:<name> could append to another user's thread by id and read
  its history back. The project path already did this correctly; mirrored, 404
  not 403 so ids cannot be probed.
- McpToken project escape. `project` travels in the request BODY, so the
  URL-based token scope check in main.ts never saw it - a token bound to project
  A could drive an agent into B's tools. The binding is now a ceiling.

Tests: 19 authority tests (intersection, fail-closed with no user and with no
RBAC wired, token ceiling, agent authority bypassing RBAC entirely, the 501) and
4 through the real chat path asserting prompts load while tools do not.

Three existing assertions updated for the new dispatcher signature - a
deliberate interface change, not tests bent to fit.

Local: build clean, workspace 2608 passed (+16), lint unchanged at 794 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-15 23:54:53 +01:00
parent f0ba6e8096
commit 2a85447fac
10 changed files with 488 additions and 18 deletions

View File

@@ -3454,19 +3454,47 @@
"dependencies": [
"166"
],
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-15T22:54:16.432Z"
},
{
"id": "178",
"title": "Agent authority: run an agent unattended on a schedule",
"description": "Mode 2. The agent acts under its OWN authority - every project it is a member of - with no human in the loop.",
"details": "The authority resolver already implements this (kind: agent returns all memberships without consulting any user, unit-tested). What is missing is anything that can INVOKE it.\n\nBlockers, all verified:\n- ChatService.chat has exactly two call sites, both HTTP handlers. No cron, no job runner, no queue that can re-enter prepareContext (InferenceTask carries raw completion bodies: no agent, no project, no tools).\n- AgentSession is schema-only: no repository, no service, no route, no credential column. Its own comment promises a GC sweep that does not exist.\n- The mcpctl toolset fails closed without a caller token by design, so a scheduled run of a system agent would throw.\n\nNeeds a scheduler, a run record, and a decision on what a scheduled run may do that a caller-authority run may not.",
"testStrategy": "See details.",
"priority": "medium",
"dependencies": [
"166"
],
"status": "pending",
"subtasks": [],
"updatedAt": "2026-08-09T22:34:45.085Z"
"updatedAt": "2026-08-15T22:54:36.185Z"
},
{
"id": "179",
"title": "Service-account authority: an authenticated non-human identity",
"description": "Mode 3. An agent runs under an explicit service account whose permission set may be smaller OR larger than any human's.",
"details": "The resolver refuses this with a 501 rather than honouring it, deliberately. Two things must exist first, both verified absent:\n\n1. A ServiceAccount is currently a forgeable header. main.ts reads x-service-account with no lookup, signature or credential; there is no ServiceAccount table. tests/security.test.ts already documents this as an escalation hole.\n2. Smaller-than-a-human is not expressible at all. middleware/auth.ts sets request.userId = ownerId unconditionally, so getPermissions always unions the owner's grants in. An McpToken with --rbac=empty is the owner at full privilege; docs/mcptoken-implementation.md claims the opposite and is wrong. A reduced principal needs that inheritance broken, not a restriction layered on top.\n\nAlso fix while there: middleware/rbac.ts route-level guards check the user only, never the SA or token, so enforcement differs by which path a route uses.",
"testStrategy": "See details.",
"priority": "medium",
"dependencies": [
"177"
],
"status": "pending",
"subtasks": [],
"updatedAt": "2026-08-15T22:54:36.185Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-08-15T22:10:49.722Z",
"lastModified": "2026-08-15T22:54:16.433Z",
"taskCount": 177,
"completedCount": 137,
"completedCount": 138,
"tags": [
"master"
]
}
}
}
}