ADR-0005: Fail-closed defaults for unknown/ambiguous state¶
Status: Accepted Date: 2026-01 (retroactive — recorded 2026-06) Issue: #1197
Context¶
An authorization gateway sits on the critical path of every tool call an
agent makes. Every component on that path will eventually see a case its
author didn't anticipate: an unregistered agent, an unrecognized MCP tool, a
gateway the SDK can't reach, an approval that's expired or already consumed,
a hash that doesn't match what was approved. The product's core promise —
"the SDK fails closed if a different/edited/expired action would execute"
(CLAUDE.md §"What AegisAgent is") — only holds if every one of these
ambiguous cases is decided in the same direction.
Decision¶
Default every unknown, ambiguous, or unreachable state to deny/refuse, never to allow:
- Unknown agent, unknown tool, unknown MCP server/tool → deny.
- Critical-risk action → deny by default; high-risk → require approval by default.
- The SDK refuses to execute the protected tool on
action_hashmismatch, on approval expiry, or when the gateway is unreachable for a mutating/high-risk action — it does not "fail open" to keep the agent unblocked. - Approvals are single-use, atomically consumed before execution — a second attempt to use the same approval is rejected, not silently re-decided.
- Trust-provenance classification only ever tightens, never loosens
(
trust_chain::propagatefor multi-hop agent chains): a downstream agent's effective trust can't exceed the most restrictive level seen anywhere upstream, even if it self-reports a more trusting context.
This is enforced as a standing invariant (CLAUDE.md §"Critical invariants
(do not weaken)") checked in code review (security_scan.md §"Secure
Defaults Audit") rather than left as an implicit convention.
Consequences¶
- An attacker (or a bug) that produces an unrecognized or malformed request is denied by construction, without needing a matching policy rule to catch every possible malformed shape — the default is the safety net.
- This trades availability for safety: a gateway outage, a network blip, or an unreachable approval store blocks mutating/high-risk actions rather than letting them through. For a security control, this is the correct trade — but it means gateway/SDK reliability work (timeouts, retries, graceful degradation for read-only/low-risk paths) carries more weight than it would for a typical CRUD service, since "the security control was briefly down" must never become "actions executed unchecked."
- Every new feature that introduces a new kind of "unknown" state (a new resource type, a new trust level, a new agent-chain hop) has to be audited against this rule explicitly — fail-closed isn't self-enforcing across new code paths; it has to be re-applied deliberately each time.
- Single-use approval consumption and tighten-only trust propagation are themselves consequences of taking fail-closed seriously at the protocol level, not just the individual-request level — they close replay and confused-deputy classes of attack that a request-by-request fail-closed check alone wouldn't catch.
Alternatives considered¶
- Fail open on gateway unreachability, log and alert instead — keeps the agent unblocked during an outage, but defeats the entire point of an authorization gateway: an attacker who can induce an outage (or simply waits for one) gets unchecked execution exactly when they want it most.
- Default-allow with an explicit deny-list — lower friction for new tool onboarding (nothing needs registering before it works), but means every unregistered tool is implicitly trusted, which is the opposite of the product's confused-deputy and provenance-gating thesis.
Revisit when¶
No planned revisit — this is a foundational invariant of the product, not a tradeoff expected to flip with scale or a new deployment tier. A change here would need to be a deliberate, reviewed decision, not an incidental side effect of unrelated work.