ADR 0029: Capability Over-Permission Derivation
Status: Proposed Date: 2026-07 Ticket: AAASM-5175
This ADR states the rule by which the capability matrix decides an agent is
over-permissioned — the derivation behind CapabilityAgent.flagged (and the
per-cell CapCell.flag) that the dashboard renders as “flagged agents” today.
Unlike ADR 0019, it does introduce a computation: the rule below is the one
the handler implements. It is written here first so the rule is a reviewable,
signed-off decision rather than a threshold invented in a route handler — the
same discipline ADR 0019 applied to the trust score.
It complements ADR 0019 (trust-score derivation — a different signal, see the
scope note below), ADR 0023/0024 (what the aa-api capability cascade is and how
an empty one is read), and ADR 0026 Decision 2 (the dashboard’s honest-absence
treatment of this very flagged tile).
Scope: this is NOT the trust score, and NOT the topology flag
Three superficially similar signals must not be conflated:
| Signal | Field | Question it answers | Owner |
|---|---|---|---|
| Trust score | CapabilityAgent.trust, AgentNode.trust, AgentTree.trust | How often does this agent trip policy at runtime? (a windowed, behavioural, audit-derived number) | ADR 0019 / AAASM-5083 |
| Topology flag | AgentNode.flagged (aa-api/src/models/topology.rs:37,55-56) | Has this agent accumulated ≥ 50 policy violations? (a violation-volume count) | topology surfaces |
| Over-permission flag (this ADR) | CapabilityAgent.flagged, CapCell.flag | Is this agent granted more than its declared posture warrants? (a static, structural comparison of grants) | AAASM-5175 |
The aa-api/src/models/capability.rs field docs previously deferred over-permission
“to the trust-score work … see trust”. That pointer is retired by this ADR:
over-permission is a structural property of the grant, not a behavioural score.
It needs no audit history, no time window, and no product-owned penalty weights —
the exact things that make the trust score a standing product decision. It is
therefore a separable, self-contained rule, which is why it ships here while
AAASM-5083 remains a To Do story and ADR 0019 remains Proposed.
Context
The field is dead at every construction site
CapabilityAgent.flagged is hardcoded None (aa-api/src/routes/capability.rs:653),
as is the per-cell CapCell.flag at all four cell-construction sites
(aa-api/src/routes/capability.rs:506,513,524,639). The model documents the field
as “a scoring rule with no implementation” (aa-api/src/models/capability.rs:216-219).
The dashboard renders flagged in a danger-toned summary tile
(dashboard/src/features/capability/CapabilitySummary.tsx), so a permanent absence
that reads as 0 would be a measured all-clear the data cannot support — the exact
untruthfulness AAASM-5175 exists to remove.
The dashboard side is already honest: countFlagged
(dashboard/src/features/capability/summary.ts) folds an all-absent flagged
column to not-evaluated rather than 0, and ADR 0026 Decision 2 records that the
tile “becomes a measurement the day one agent carries a boolean”. This ADR provides
that boolean.
What the projection already holds — no new source needed
The capability matrix is a read-only projection (module doc,
aa-api/src/routes/capability.rs:1-38): it evaluates nothing at runtime and reads
only the agent registry plus the policy engine’s capability cascade. Two inputs
relevant here are already in that projection, per agent:
- The effective, merged capability set —
collect_merged_capabilitiesover the agent’s cascade, reduced to a per-(resource,verb)Decisionbydecide(aa-api/src/routes/capability.rs:480-488) using the same most-restrictive-wins helpers the enforcement guard uses. This is what the matrix cells already show. - The agent’s declared
RiskTier—AgentRecord.risk_tier(ani32proto value), converted withaa_core::RiskTier::from_proto_i32(aa-core/src/risk_tier.rs), which returnsNonefor the0/ UNSPECIFIED sentinel and any out-of-range value. The gateway already reads the tier this way (aa-gateway/src/policy/context.rs:92).
So the over-permission rule can be computed entirely from data the projection already loads, touching no audit log, no time window, and no enforcement path.
The signals considered
Two candidate signals were weighed (both named in the ticket):
- Grants never exercised in the audit window (unused-grant detection).
- Grants exceeding the agent’s declared risk-tier baseline (this ADR’s choice).
Signal 1 is deliberately rejected below because it drags the audit log — with all of ADR 0019’s durability, tenant-scoping (IDOR), and 100k-truncation caveats — into a projection whose defining property is that it evaluates nothing. Signal 2 needs none of that.
Decision
Over-permission = the agent is effectively granted a destructive/high-blast-radius
system capability that its declared RiskTier baseline does not warrant.
Concretely, in project_matrix, for each agent:
-
Resolve the agent’s tier:
tier = RiskTier::from_proto_i32(record.risk_tier). IftierisNone(undeclared / UNSPECIFIED), the agent is not evaluated —flaggedand everyflagstayNone. A missing baseline is a missing comparison, not a clean bill of health. -
For a resolved tier, take the tier’s allowed high-privilege set from the fixed table below. The high-privilege capabilities under consideration are the destructive / high-blast-radius system verbs the matrix already models:
FileWrite,FileDelete,TerminalExec,NetworkOutbound. (FileReadis not high-privilege;Model,NetworkInbound,AgentSpawnare inert —Capability::is_enforceable— and never reach a cell.) Named MCP tools are out of scope for the baseline (see Accepted risks).Tier Baseline-allowed high-privilege system capabilities Low(none) — log-only posture; any destructive grant is over-permission MediumFileWrite,NetworkOutboundHighFileWrite,FileDelete,NetworkOutbound,TerminalExecCriticalFileWrite,FileDelete,NetworkOutbound,TerminalExec -
A cell is flagged (
flag: Some(true)) when the agent’s effective decision for one of that cell’s high-privilege verbs isDecision::Allowand that capability is not in the tier’s baseline set. Only granted (Allow) capabilities can be over-permission — aDeny/Nacell is never flagged. -
The agent is flagged (
flagged: Some(true)) iff at least one of its cells is flagged. Thenotenames the offending grants and the tier, e.g. “Low-risk agent granted file_delete, terminal_exec beyond its tier baseline”, so the operator sees why without opening every cell. -
A resolved tier whose grants are all within baseline is flagged
Some(false), explicitly. This is the one place a booleanfalseis emitted: the agent was evaluated and found within baseline. This is a real measurement, not an absence, and the dashboard’scountFlaggedalready treats “any agent carries a boolean” as the column becoming evaluated.flagon individual non-offending cells staysNone(absent) — a cell-levelflag: falsewould clutter every cell with a negative marker the UI does not consume.
The rule is fail-absent, never fail-flag: no input (missing tier, empty cascade)
ever produces a fabricated true. An empty/unavailable cascade makes every cell
Allow by decide’s fall-through (ADR 0024), which could mass-flag every
low-tier agent — so the evaluation is skipped entirely when the agent’s cascade is
empty, mirroring how the dashboard folds an empty cascade to unconfigured
rather than counting its cells.
Accepted risks
RiskTieris self-declared at registration (aa-core/src/risk_tier.rs), the same property ADR 0019 called out. For a trust score that is disqualifying — the measured party sets its own baseline. For over-permission it is defensible and even desirable: the agent declaringLowwhile holdingterminal_execis precisely the contradiction an operator wants surfaced. The flag says “your grants disagree with your declared posture”, which is true regardless of who declared the posture. The note states the tier so the operator can judge whether to tighten the grant or re-declare the tier.- Named MCP tools are excluded from the baseline. A per-tool danger
classification does not exist in the capability model (there is no tool-severity
enum), so weighting
mcp_tool:delete_prod_dbovermcp_tool:echowould be an invented derivation of exactly the kind this ADR refuses to smuggle in. Tools are left out until a real classification exists; the rule covers the system verbs that do carry an intrinsic blast radius. - The tier→baseline table is a judgement, but a small, bounded, and reviewable
one grounded in the tier definitions themselves (
risk_tier.rs:Low= “log-only … no blocking”;High/Critical= “always block; human review”). It is not five free-floating penalty weights; it is a monotone allow-list that widens with severity. It is stated here to be ratified, not inherited silently.
Forbidden designs
- Do not read the audit log for this signal. Unused-grant detection (candidate signal 1) is rejected: it couples a static projection to windowed audit data with ADR 0019’s truncation and cross-tenant (IDOR) hazards, for a signal that does not need it.
- Do not emit a fabricated flag. Absent stays absent (undeclared tier, empty
cascade). Never
Some(true)from missing data. - Do not reuse the topology
flagged(policy_violations_count >= 50) here — different question, different field, andpolicy_violations_countis a dead field in production anyway (ADR 0019).
Consequences
- Positive: the danger-toned “flagged agents” tile and the per-cell markers light up from a stated rule computed off data already in the projection; no enforcement path, audit read, or new endpoint is introduced, so this is mergeable without the ADR 0018 hot-path gate.
- Positive: the signal is explainable to an operator in one sentence and the
notecarries the reason inline. - Negative / accepted: the rule measures grant-vs-declared-posture, not actual
risk of the specific tool/path. A
High-tier agent holding every system verb is never flagged even if it never uses them — that is unused-grant territory (candidate signal 1), explicitly out of scope. - Neutral: agents that register without a risk tier show no flag at all. This is
correct (no baseline → no comparison) but means a fleet of untiered agents shows an
all-absent column, which the dashboard renders as
not-evaluated— the honest answer.
Validation requirements
- A
Low-tier agent effectively grantedterminal_exec(orfile_delete) isflagged: Some(true), the offending cell isflag: Some(true), and thenotenames the grant. - A
High-tier agent granted the same capabilities isflagged: Some(false)— the grant is within its baseline. - An agent with no resolvable tier (
risk_tier = 0) isflagged: Noneand carries noflagon any cell — neverSome(false), neverSome(true). - An agent whose cascade is empty is not flagged (no
Allow-from-fall-through mass flag). - A denied high-privilege capability is never flagged (only
Allowcounts).
Reconsideration triggers
- A per-tool or per-capability danger classification landing (would let MCP tools and finer file-path scopes enter the baseline).
- ADR 0019’s trust score shipping — if a behavioural score exists, an unused-grant over-permission signal (candidate 1) becomes tractable to add as a second, audit-derived dimension alongside this structural one.
- A registration-time attestation of risk tier (removing the self-declared caveat).
Traceability
- Implements AAASM-5175.
- Distinct from the trust score of ADR 0019 / AAASM-5083 and the topology flag; see the scope table above.
- Consumes the honest-absence treatment ratified in ADR 0026 Decision 2
(AAASM-5187): the dashboard already folds an absent
flaggedcolumn tonot-evaluatedand treats one real boolean as the column becoming evaluated.
Last updated: 2026-07-29 by Chisanan232