Story 1838: excessive-agency regression family — a standing tripwire on the four ways an agent could overreach
Why this exists (the catastrophe it prevents). The OWASP-LLM “excessive agency” failure: an agent that does too much — auto-executes an irreversible write, runs a tool it was never granted, takes its authority from whatever a document told it, or acts on a forged tool name the model hallucinated. PRAMAAN’s posture against this is already live across three modules: the server-tool registry (the leash), the executor (read runs, write proposes), and the authority resolver (auto-vs-HITL never crosses a non-delegable act). What it lacked was a standing guard. A posture with no regression family is one careless PR — a new write tool wired to a read handler, a new authority mode that auto-applies, a tool that quietly stops gating on its own permission — away from silently regressing, with green feature tests all the way down. This Story adds the tripwire.
tests/integration/test_excessive_agency_regression.py, that converts four security invariants into comprehensive sweeps that fail the moment any of them is weakened.Satellite view: four invariants, swept — not sampled
Existing tests prove each surface with hand-picked examples. The risk is the example that nobody thought to add. So this family sweeps the full registry and the full authority matrix — a new tool or a new mode is automatically in scope the day it lands.
1. write_proposal never executes ∀ WRITE_PROPOSAL tool → no read handler exists
2. hard_block never auto-applies ∀ AuthorityMode × HARD_BLOCK risk → decision ≠ AUTO_APPLY
∀ AuthorityMode × non-delegable act → decision ≠ AUTO_APPLY
3. fail closed unknown tool → denied; ∀ tool with no grant → denied
4. least privilege / no deputy ∀ tool: sibling permissions do NOT grant it;
authority is (name, session-grants) ONLY — never a client claim
The decisions that matter
1. Sweep the whole set, so a future addition can’t slip the net
Every parametrization is driven off the live registry and the live enum — SERVER_TOOL_REGISTRY, list(AuthorityMode), NON_DELEGABLE_ACTIONS — not a hand-written list. Add a seventh server tool and test_write_proposal_tool_has_no_execution_path / test_least_privilege_sibling_permissions_do_not_grant cover it on the next run with no edit. Add an authority mode and test_hard_block_action_never_auto_applies immediately demands it hold the hard-block line. A guard that does not grow with the code is a guard that rots; this one grows automatically. A test_registry_has_at_least_one_write_tool_to_guard sentinel keeps the write sweeps from passing vacuously if the registry ever empties.
2. The confused deputy is proven through the real route, not a mock
The sharpest case — untrusted document content pre-filling a write proposal — is exercised end-to-end through the live POST /app/agents/tools/execute handler against a real DB (the system boundary, per this repo’s “done = system boundary” rule). The intent’s params carry an attacker’s payload: auto_apply: true, a forged required_permission: "none", a forged granted list, and another firm’s firm_id. The executor still returns a proposal, mutates nothing in either firm, and stamps the proposal’s required_permission from the server registry spec — never from the params. The forged data is inert.
3. Authority is re-resolved from the verified session — the deputy holds even when the session can’t
The second confused-deputy test runs the same poisoned intent from a firm_member (read-only) caller. The forged granted in the params cannot manufacture write authority: the server resolves the real permission set from the verified membership role, the call is denied, and the denial is audited. At the pure layer this is structural, not incidental — classify_server_tool(name, granted) takes only the tool name and the session-resolved grant set; a client’s permissions/clientTools block is not even an argument, so it cannot tilt the decision.
4. Least privilege: a tool is reachable only on its OWN permission
For every registered tool, the family asserts that a caller holding every other tool’s permission but not this one’s is still denied — authority never leaks across siblings. query_clients cannot be reached on matters:read; propose_forum_create cannot be reached on matters:write. The dock_chat catalog is checked too: a WRITE_PROPOSAL tool never appears as a callable read, even to a caller who holds its permission, so the brain is never told it may run one.
5. No new observability span (and why that is correct)
Per pramaan-docs ADR-124, new executable code declares a span or documents its exemption. This Story adds no executable product code — only tests — and tests are not an instrumented boundary. The route and executor under test keep their existing auto-instrumented spans; nothing here changes them.
Verification
73 pure assertions (no DB) plus 2 real-route confused-deputy cases (real Postgres, full ASGI pipeline) — 75 in all.
| Check | Proves |
|---|---|
test_write_proposal_tool_has_no_execution_path (sweep) | no WRITE_PROPOSAL tool has an execution handler — it can only ever be proposed |
test_hard_block_action_never_auto_applies / test_non_delegable_action_never_auto_applies (sweep) | across every authority mode, a hard-block or non-delegable act is never AUTO_APPLY |
test_unknown_tool_fails_closed / test_known_tool_denied_with_no_grants (sweep) | a forged tool name and any ungranted tool are denied — fail closed by default |
test_least_privilege_sibling_permissions_do_not_grant (sweep) | holding every sibling permission but a tool’s own one is still a denial |
test_confused_deputy_prefilled_write_proposal_does_not_auto_apply (real route) | untrusted-prefilled write intent returns a proposal, mutates nothing, authority from the server spec |
test_confused_deputy_without_grant_is_denied_authority_from_session (real route) | the same poisoned intent from a read-only session is denied — forged grants are inert, denial audited |
Ruff is clean; mypy reports no issues in the new file (the 502 pre-existing errors are in unrelated modules and mypy is local-only); all 75 tests pass against the isolated Docker test DB; no route/DTO/migration was touched, so the OpenAPI snapshots are unchanged; git diff --check is clean.
One sentence to remember: PRAMAAN’s excessive-agency posture now has a standing regression family that sweeps the whole tool registry and the whole authority matrix — so the day a write tool gains an execution path, a mode auto-applies a hard-block, a tool stops gating on its own permission, or a forged document tries to drive a write, a security test goes red.