ADR-0011: Honest framing — Capability is audit + lint (NOT runtime-unforgeable); SandboxedPath is in-jail-at-construction (NOT in-jail-forever); PLUGINS.lock is integrity check (NOT cryptographic signature)¶
Status: Accepted Date: 2026-05-17 Tags: threat-model · honest-framing · capability · capability-claim-discipline · phase-11-precursor Related: 0006, production ADR-0009
Context¶
The security-first lens design (design-security.md) made three claims the critic correctly attacked in critique.md §Attacks on the security-first design:
*Capabilitytokens are "unforgeable at runtime." False. Pydantic models can be constructed anywhere in the codebase that imports them; the type system doesn't know its caller. A determined plugin author canNpmInstallCapability(registry="...")directly, bypassingmint().SandboxedPath"makes illegal states unrepresentable" (in-jail forever). False.Path.resolve(strict=True)resolves symlinks at constructor time, but a symlink swap betweencreate()andopen()re-introduces the TOCTOU. The path is in-jail at construction, not forever.PLUGINS.lockis a "signature". False. It's a SHA-256 tree-hash; a committer with write access who updates both the plugin tree ANDPLUGINS.lockpasses the runtime check. "Signature" implies a private-key-signed assertion verifiable without trusting the committer; this is not that.
The critic correctly argued that overclaiming security properties is worse than honest framing because:
- Downstream phases (Phase 5 retry envelope; Phase 11 PR creation) build on top of these primitives and may assume properties that don't hold.
- An operator reading the docs and trusting "unforgeable" makes worse decisions than one trusting "audited."
- The actual mitigations (lint enforcement, O_NOFOLLOW, CODEOWNERS + PR review) are useful but only if framed as what they are.
The architecture spec resolves it via honest framing (phase-arch-design.md §Component design C10, §Tradeoffs, §Departures from all three inputs #9 + #12): the primitives ship, the docs and ADRs describe what they actually do, and the gaps are made explicit.
Options considered¶
- Option A — Adopt security's framing verbatim ("unforgeable", "unrepresentable", "signature"). Pattern: Capability pattern, overclaimed. Downstream phases inherit assumptions that don't hold. Worse than no claim because it stops the next reader from asking the right questions.
- Option B — Drop the primitives entirely; rely on convention. Pattern: No defense. Loses the genuine value of capability audit trails,
O_NOFOLLOWsecond-line defense, and SHA-256 integrity-check (which catches real accidental corruption). - Option C — Ship the primitives with downgraded, honest framing.
Capability= "audit + lint enforcement, NOT runtime unforgeability";SandboxedPath= "in-jail at construction, second-line defense atopen()time viaO_NOFOLLOW";PLUGINS.lock= "integrity check (catches accidental corruption), NOT cryptographic signing — Phase 11 ships Sigstore." Pattern: Honest capability pattern — claims match what the implementation actually delivers.
Decision¶
Adopt Option C. Three primitives ship with explicit framing:
Capability tokens¶
NpmInstallCapability, FsReadWriteCapability, GitLocalOpsCapability, CapabilityBundle are typed Pydantic models. Their value is:
- Audit trail — mint() is the single chokepoint that emits a CapabilityMinted spanning event; CapabilityUsed events tie operations to capability use.
- Lint enforcement — a pure-Python AST walker (src/codegenie/_capability_fence.py; S4-05) AST-walks src/codegenie/ (plus future plugin roots) and reports any *Capability(...) construction outside capabilities.py or tests/. Consumed from tests/fence/test_capability_fence.py as a CI-gating check. (S4-05 picked the in-tree fence-module convention over the ADR's originally-stated tooling/ruff_rules/no_capability_construction.py path: the codebase has no tooling/ directory and src/codegenie/_phase3_fence.py is the established precedent — Rule 7 + Rule 11.)
Framed as: audit + lint, NOT runtime unforgeability. Threat model assumes first-party plugins.
GitLocalOpsCapability has no push field; minting one is type-impossible. This IS a real type-level invariant for one specific operation (per ADR-0009 humans-always-merge).
SandboxedPath¶
SandboxedPath.create(jail, relative) resolves with strict=True, checks is_relative_to(jail), returns Result[SandboxedPath, PathEscape]. open() always uses O_NOFOLLOW.
Framed as: in-jail at construction, second-line defense at open() time via O_NOFOLLOW. Consumers handle OSError(errno=ELOOP) and emit FilesystemRaceDetected (workflow-internal event). TOCTOU is real and acknowledged.
PLUGINS.lock¶
Per-plugin tree SHA-256 digests recomputed at loader startup. Mismatch → PluginRejected(integrity_mismatch) + exit 4.
Framed as: integrity check (detects accidental corruption, partial merges, file-system damage), NOT cryptographic signing. CODEOWNERS on plugins/PLUGINS.lock + PR review is the social anchor. Phase 11 ships Sigstore-based plugin signing (production roadmap).
Tradeoffs¶
| Gain | Cost |
|---|---|
| Downstream phases (Phase 5, Phase 11) build on what the primitives actually deliver; no inherited overclaim | First-time reader may wonder why Phase 3 ships a Capability "system" that is "only" audit + lint — answer is in this ADR |
| The lint rule + audit trail is the real defense against accidental capability misuse; the framing matches the mechanism | A determined adversary with commit access still wins; threat model documents this explicitly |
O_NOFOLLOW is a meaningful second-line defense — caught at open() time, raises typed FilesystemRaceDetected event |
Every consumer of SandboxedPath.open() must handle OSError(errno=ELOOP); lint or fence catches missed cases |
PLUGINS.lock integrity check catches accidental drift (a contributor edits a plugin file but forgets to regenerate the lock) — real value |
"Integrity check" sounds weaker than "signature"; messaging discipline needed in docs |
| Phase 11's Sigstore work has a clear seam to plug into — replaces the SHA-256 tree digest with a real signature verification | Two-stage migration: Phase 3 ships integrity check; Phase 11 substitutes signing. Documented forward path |
GitLocalOpsCapability without push is a real type-level invariant for the one operation that matters most — humans always merge (ADR-0009) |
One token has stronger semantics than the others; convention "no push here means no push anywhere" is doc + lint, not type |
Pattern fit¶
Implements Capability pattern (toolkit §Composition / coupling patterns) at the audit-trail-and-lint-enforcement tier — the pattern recognized for what it actually delivers in a Python codebase without runtime privilege separation. Avoids the failure mode "every capability check is a runtime guarantee even when it's just a typed wrapper around a dict." Rejects security's overclaim ("Capability" framed as "unforgeable") which would be the toolkit's "is_admin boolean checked everywhere" failure mode wearing a wrapper — namely, a token system trusted as a runtime guarantee that doesn't enforce anything at runtime.
Consequences¶
src/codegenie/plugins/capabilities.pyhousesmint()and the capability types;src/codegenie/_capability_fence.py(S4-05; mirrors_phase3_fence.py's shape) is the AST walker enforcing the lint, consumed fromtests/fence/test_capability_fence.py.src/codegenie/plugins/sandbox_path.pyshipsSandboxedPath.create(returningResult) andSandboxedPath.open(alwaysO_NOFOLLOW);tests/unit/plugins/test_sandbox_path.pyexercises the TOCTOU swap via deliberate fixture.src/codegenie/plugins/loader.pyperforms per-plugin SHA-256 tree digest verification on startup; mismatch raisesPluginRejected(integrity_mismatch).tests/fence/test_capability_fence.py(S4-05; undertests/fence/, matching_phase3_fence's locality) runs the AST walker across the repo as a CI-gating check; planted-positive fixtures prove the walker is alive.CODEOWNERSincludesplugins/PLUGINS.lockmapped to the platform team; PR template calls out lockfile changes.- The docs framing this ADR establishes is reused verbatim in operator runbooks — "audit + lint" not "unforgeable"; "integrity check" not "signature."
- Phase 11 substitutes
PLUGINS.lockwith Sigstore signing; the loader interface stays the same (verify_plugin(plugin_dir) -> Result[None, VerificationError]). - Phase 5's gate policy can read
CapabilityUsedevents to enforce per-workflow capability budgets — the audit trail is the substrate.
Reversibility¶
High (for framing). Re-framing is a docs-only change; no code change required to switch the prose. Medium (for the primitives). Removing the capability types entirely would lose the audit trail and lint scaffold; adding Phase 11's Sigstore signing substitutes one verification mechanism for another via the loader interface.
Evidence / sources¶
../phase-arch-design.md §Component design C10, §Tradeoffs, §Departures from all three inputs #9 + #12../final-design.md §Synthesis ledger rows "Capability tokens"(score 15/15), "SandboxedPathframing" (score 15/15), "Plugin loader trust model" (score 13/15)../critique.md §Attacks on the security-first design — capability + sandbox-path overclaim + signature mislabel, §Misapplied patterns- production ADR-0009 — humans always merge
- design-patterns-toolkit.md §Capability pattern (with failure-mode callout)