20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 639 | pi-permission-system: decide the permission policy model — capabilities, config shape, prior art (ADR 0009) |
ADR 0009 — permission policy model: capabilities, config shape, prior art
Release Recommendation
Release: ship independently
This is a documentation-only decision record: it touches docs/decisions/ and docs/architecture/, both release-please exclude-paths, so it cuts no physical release on its own — the same posture as the ADR 0007 plan (#591).
The decisions it records are implemented later (starting with #609's re-plan), and those changes release on their own merits.
Problem Statement
Issue #609 (third-party, filed by hcrosse) asks for output redirects to be governed separately from the command: an allowed bash command should not implicitly carry the right to write files through >/>>.
Planning it exposed a general gap: access direction/capability is not a first-class fact anywhere in the model — the cross-cutting path surface is direction-blind, and bash path tokens have no read/write identity at all.
The operator widened the question deliberately: rather than bolt on one key, decide the permission policy model itself, with nothing locked down going in — including the current config format.
The deliverable is ADR 0009, settled interactively during the build session. The #581 lesson applies in full: the deliberation is the deliverable; the ADR must record decisions actually made with the operator, not transcribe the sketches produced during this planning conversation.
The operator's decision criteria, stated verbatim in planning: clarity; simplicity ("straightforward, avoiding complex calculus of interactions between rules, and ambiguity"); designed for our users first.
Goals
- Author
docs/decisions/0009-permission-policy-model.mddeciding the future shape of the permission policy model, with every decision settled interactively during the/build-plansession. - Survey prior art with citations before deciding — agent tools (OpenCode, Claude Code, Codex CLI) and capability systems (Deno permissions, Node's permission model, Linux Landlock, macOS Seatbelt, WASI preopens, systemd sandboxing) — extracting each system's policy axis, naming, composition semantics, default stance, and unknown-handling.
- State the threat model explicitly in the ADR: a cooperative-but-fallible agent; attention routing, not containment; an OS sandbox as the eventual enforcement seam this design should be able to hand classifications to.
- Evaluate the full option space (documented in Design Overview) against the operator's criteria, recording rejected alternatives with reasons.
- Decide the staging: what #609's implementation builds first, unblocking its re-plan.
Non-Goals
- Implementing anything — no
src/,test/,schemas/,config/, orREADME.mdchange; current behavior is untouched. Issue #609 stays open and is re-planned after the ADR lands. - Executing any config migration or renaming, even if the ADR decides one — implementation issues carry that work.
- Filing speculative follow-up issues for stages beyond #609 (e.g. read-side wiring, a command-effects knowledge base, net-egress effects) — the ADR's staging section names them; filing happens at the next
/plan-improvementspass or during #609's re-plan, whichever the ADR directs. - Redesigning the live-authority layer (the ADR 0007 authorizer chain) — this ADR is about the deterministic policy model the chain consults, not about who holds live authority.
Background
The current model, mapped
Every gated action has three independent facts: an actor (which tool/channel), an object (which path, command, server:tool, skill name), and a capability (read fs, write fs, execute, cross the cwd boundary). Today's flat permission map expresses:
| Key today | Axis expressed | Notes |
|---|---|---|
read/grep/find/ls |
actor (implies capability: read) | path-matched |
write/edit |
actor (implies capability: write) | two keys, one capability; users must set both |
bash |
actor (capability: exec) | command patterns; decomposition + wrapper floors |
mcp, skill, special, tool names |
actor | |
path |
object, capability-blind | any file access, any direction, any actor |
external_directory |
boundary | outside-cwd guard, orthogonal to the above |
* |
universal fallback |
Composition is most-restrictive-wins across surfaces, last-match-wins within a surface.
OpenCode's model (the fork's origin) is actor-keyed with two guards; its only nod to direction is collapsing edit/write/patch into one edit key, and its bash gating is command-pattern only.
The engine seams that price the options
AccessIntent(tool|access-path) carries a free-formsurfacestring; the resolver and manager do not care what the keys mean. New surface keys are additive.- Every policy channel speaks flat
(surface, pattern)pairs: session-approval rules, the forwarded-intent wire (#596), the cross-extensionPermissionsServicequeries, and per-agent frontmatter. A nested config shape would be flattened internally regardless; a format remodel churns all four channels. - Bash path tokens are gated on
path+external_directoryonly. Redirect targets are collected bycollectRedirectTokensbut then shape-filtered like any other token, so a bare in-cwd target (> out.txt) is not even a rule candidate today, andBashProgram.commands()deliberately strips redirects from command text — the exact #609 gap.
The evidence: capability keeps escaping pattern matching
The fork's history is a catalog of the same event — a capability escaping command-pattern rules through a syntactic synonym:
| Issue | Escape channel |
|---|---|
| #301 | whole-string matching bypassed by chained commands |
| #306 | command/process substitution executes unseen |
| #393 | cd folding; unknown bases must stay literal-only |
| #452 | unparseable commands must fail closed |
| #481 | bash -c/eval opacity; env-var prefixes |
| #490, #575 | indirection wrappers (sudo, env, xargs, find -exec, …) |
| #509 | bare tokens invisible to shape classifiers |
| #533 | platform reinterprets the same token |
| #574 | other extensions re-expose bash under new names |
| #609 | output redirects ride the command's allow |
The structural parts age well (AccessPath alias matching #418, the boundary gate, fail-closed floors, determinism); the command-spelling parts are a treadmill.
This evidence motivates the reframe the ADR must weigh: policy keyed by effects, with command patterns as evidence and refinement.
Leanings from the planning conversation — explicitly not decisions
The operator unlocked everything ("none of the decisions we've explored today are locked-down"), so these are inputs to the deliberation, not settled outcomes:
- Naming leaning:
path_read/path_write(path-family) overfs.read/fs.write— though thefsprefix is the cross-ecosystem convention (Node's--allow-fs-read/--allow-fs-write, LandlockACCESS_FS_*, Seatbeltfile-read*/file-write*), Deno-style bareread/writecollides with our existing tool keys. - Nesting (
path: { read: …, write: …, "*": … }) was analyzed and found grammatically ambiguous:path: { "read": "allow" }is already a valid pattern rule matching a file literally namedread, and the map-valued discriminator collides withdenyWithReasonobject values. The analysis rides into the ADR; nesting remains evaluable as sugar over flat keys. - An earlier gate (under the narrower flooring framing) chose a shipped default of
askfor output-redirect writes (breaking,feat!:) — recorded as a leaning toward least-privilege defaults, explicitly reopened with everything else. - The effect-centered sketch (effects primary; structural proof + a command-effects knowledge base + honest unknowns;
netas a future effect; effect-level session approvals; sandbox-handoff seam) is one candidate among several, not the presumed target.
Standing constraints
- Config files are the source of truth; no policy baked into code; a declared config field not read at runtime is a maintenance trap.
- Determinism: same policy + same input → same decision (no
cygpath, no environment reads in matching). - Least privilege and fail-closed stay non-negotiable; the composition semantics that deliver them are what the ADR may redesign.
- The architecture doc inline-copies the
rule.tstypes; this ADR changes no types, but any implementation that does must update that listing. - ADR markdown follows the
markdown-conventionsskill (one-sentence-per-line, reference-style issue links, MD053 discipline).
Design Overview
This plan deliberately does not settle the design. It defines the decision framework the build session executes.
Decision criteria
- Clarity — a user can predict what a config does by reading it.
- Simplicity — straightforward; avoid a complex calculus of interactions between rules. This criterion cuts at the multi-surface most-restrictive lattice itself, so composition semantics are on the table, not just key names.
- No ambiguity — no config text with two plausible readings (the nesting analysis is the cautionary example).
- User-first — ergonomics of authoring, reading prompts, and approving sessions outrank internal elegance.
- Retained unless deliberately revisited: determinism, least privilege, fail-closed unknowns.
Option space to evaluate
- O1 — status quo + targeted
path_writekey. Smallest change; fixes #609; direction stays a bolt-on. - O2 — capability family as flat keys.
path_read/path_writejoinpath+external_directoryas a capability/boundary layer; actor keys become refinement; most-restrictive lattice retained. - O3 — nested facets under
path. Grouping sugar; carries the documented grammar ambiguity; internally flattens to O2. - O4 — effect-centered model.
Effects primary (
fs read/write,exec, laternet), classified by structural proof, a curated command-effects knowledge base (consolidatingPATTERN_FIRST_COMMANDS, the wrapper sets,SAFE_SYSTEM_PATHS), and an explicit unknown category the user must deliberately allow; flat keys; actor keys as refinement. - O5 — full config remodel. A v2 format with capability domains primary and tools demoted; prices in a dual-format loader window and churn across all four flat-pair channels.
- O6 — single ordered rule list. A firewall-style model: one ordered list of typed rules (match on effect/path/command/tool → action), first- or last-match wins; trades the lattice's cross-surface calculus for explicit ordering. Included because criterion 2 questions the lattice; the ADR must evaluate whether ordering is simpler or merely different.
Prior-art survey scope
For each system, extract: policy axis (actor/capability/effect), key naming, composition and conflict semantics, default stance, unknown-handling, and prompt/escalation UX.
| System | Why it matters |
|---|---|
| OpenCode | the fork's origin; actor-keyed baseline |
| Claude Code | agent-adjacent; Tool(specifier) allow/ask/deny lists + sandbox modes |
| Codex CLI | agent-adjacent; approval modes paired with an OS sandbox |
| Deno | cleanest capability flags (--allow-read/--allow-write/--allow-net/--allow-run) |
| Node permission model | literally --allow-fs-read/--allow-fs-write |
| Linux Landlock | kernel fs capability rights (ACCESS_FS_*) |
| macOS Seatbelt | operation-named profile rules (file-read*, process-exec) |
| WASI | capability handles; preopened dirs; path_open rights |
| systemd | path-scoped mode lists (ReadOnlyPaths=, ReadWritePaths=) |
Open parameters the build session settles interactively
- Policy axis: actor-keyed (status quo), +capability layer, or effect-primary.
- Composition semantics: retain the most-restrictive lattice, or simplify (O6-style ordering, or a narrower layer set).
- Key naming and shape:
path_read/path_writevsfs-prefixed vs nesting sugar. - Unknown-effects stance: inert-when-absent vs an explicit unknown category in policy.
- Defaults and breaking posture for redirect writes (leaning: shipped
askdefault,feat!:— reopened). - Whether the command-effects knowledge base becomes a named, curated asset (and when).
- Staging: what #609 implements first; which follow-ups the ADR names and where they get filed.
The gate protocol during /build-plan: complete the survey first, present findings and option evaluations, then run ask_user gates per parameter cluster before authoring a word of the ADR — decisions precede prose.
Module-Level Changes
Documentation only.
- New:
packages/pi-permission-system/docs/decisions/0009-permission-policy-model.md— the ADR: threat model, decision criteria, current-model inventory, prior-art survey with citations, options considered with rejected alternatives and reasons, the settled decisions (parameters 1–7), staging, and consequences. The 0009 slot is next (0001–0008 taken); the file slug may sharpen at build time to reflect the settled decision, keeping the 0009 number. - Changed:
packages/pi-permission-system/docs/architecture/architecture.md— link ADR 0009 from the design-principles section and reconcile any prose the settled decision contradicts (candidates: the most-restrictive-wins principle statement, thepath-surface description, any aspirational prose about policy evolution). Grep the whole file for stale framing after the decision is known — the #581 failure mode was un-reconciled prose surviving an internally consistent ADR. - Not edited:
docs/architecture/history/*,docs/plans/*,docs/retro/*— frozen point-in-time records;README.md,docs/configuration.md,schemas/,config/— they describe current behavior, which this ADR does not change;.pi/skills/package-pi-permission-system/SKILL.md— it documents current behavior and constraints, all still true.
Test Impact Analysis
Not applicable — the deliverable is a decision record with no code. Tests the settled design enables (recorded for #609's re-plan to inherit): redirect-operator classification (output vs input vs fd-duplication), unconditional collection of output-redirect targets, capability-surface resolution and composition, and — if the ADR adopts them — knowledge-base row lookups and unknown-category resolution.
Invariants at risk
- The #581 transcription failure.
This planning conversation produced sketches and leanings; the ADR must not launder them into settled status.
Mitigation is structural: the Build Order places the survey and the
ask_userdecision gates before ADR authoring, and the plan marks every leaning as reopened. - Cross-doc consistency.
If the decision revises composition semantics or the
pathfamily, the architecture doc's principle statements must be reconciled in the same change, verified by a whole-file grep for the superseded framing (most-restrictive,pathfamily descriptions, aspirational policy prose). - Current-behavior docs stay true.
README.mdanddocs/configuration.mddescribe shipped behavior; the ADR decides future direction and must not cause edits that make current-behavior docs describe unshipped design.
Build Order
Documentation-only, so /build-plan (no red→green cycles).
Numbered docs: commits, each leaving the docs internally consistent.
- Survey prior art.
Research the nine systems in scope (web sources with citations;
librarian/fetch_contentfor primary docs), producing per-system extractions of policy axis, naming, composition, defaults, unknown-handling, and prompt UX. No commit — this is input to the deliberation. - Deliberate and settle.
Present the survey findings and the O1–O6 evaluations against the criteria; run
ask_usergates covering the seven open parameters (clustered: axis+composition; naming+shape; unknowns+defaults; knowledge base+staging). No commit — decisions precede prose. - Author ADR 0009.
Write
docs/decisions/0009-permission-policy-model.mdrecording the threat model, criteria, survey, options with rejected alternatives, the settled decisions, staging for #609, and consequences. Verify withpnpm exec rumdl checkon the new file. Commit:docs(pi-permission-system): record ADR 0009 deciding the permission policy model (#639). - Reconcile the architecture doc.
Link ADR 0009 and reconcile any contradicted prose in
docs/architecture/architecture.mdin one commit; run the whole-file grep from Invariants at risk; verify any touched Mermaid diagrams still render. Commit:docs(pi-permission-system): reconcile architecture with ADR 0009 (#639).
Risks and Mitigations
- Risk: transcription instead of deliberation (the #581 revert). Mitigated: survey-then-gates-then-prose ordering in the Build Order; leanings explicitly marked reopened in Background.
- Risk: a shallow survey that just confirms the effect-model sketch. Mitigated: the per-system extraction template forces comparable facts (axis, composition, defaults, unknowns) rather than cherry-picked naming; O6 is in the option space specifically because it challenges the sketch's lattice.
- Risk: the ADR over-commits implementation detail. Mitigated: the ADR settles model, semantics, naming, defaults, and staging; schemas, migration mechanics, and knowledge-base contents belong to the implementation issues.
- Risk: scope creep into code.
Mitigated: Non-Goals fences this to
docs/; #609 and successors implement. - Risk: a breaking-default decision ships without migration discipline.
Mitigated: if the settled decision is breaking, the ADR records the posture, and the implementing issue's plan carries the
feat!:/BREAKING CHANGE:footer and a verified migration note — not this ADR.
Open Questions
- The seven open parameters in Design Overview — deliberately open; they are the ADR's subject.
- Whether nesting sugar over flat keys is ever worth its grammar cost — evaluable in the ADR, decidable later without model change if deferred.
- Where the ADR-named follow-ups get filed (during #609's re-plan vs the next
/plan-improvementspass) — settled by the ADR's staging section.