Files
my-pi/pi-permission-system/docs/plans/0639-permission-policy-model-adr.md
T

20 KiB
Raw Blame History

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.md deciding the future shape of the permission policy model, with every decision settled interactively during the /build-plan session.
  • 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/, or README.md change; 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-improvements pass 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-form surface string; 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-extension PermissionsService queries, 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_directory only. Redirect targets are collected by collectRedirectTokens but then shape-filtered like any other token, so a bare in-cwd target (> out.txt) is not even a rule candidate today, and BashProgram.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) over fs.read/fs.write — though the fs prefix is the cross-ecosystem convention (Node's --allow-fs-read/--allow-fs-write, Landlock ACCESS_FS_*, Seatbelt file-read*/file-write*), Deno-style bare read/write collides 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 named read, and the map-valued discriminator collides with denyWithReason object 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 ask for 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; net as 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.ts types; this ADR changes no types, but any implementation that does must update that listing.
  • ADR markdown follows the markdown-conventions skill (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

  1. Clarity — a user can predict what a config does by reading it.
  2. 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.
  3. No ambiguity — no config text with two plausible readings (the nesting analysis is the cautionary example).
  4. User-first — ergonomics of authoring, reading prompts, and approving sessions outrank internal elegance.
  5. Retained unless deliberately revisited: determinism, least privilege, fail-closed unknowns.

Option space to evaluate

  • O1 — status quo + targeted path_write key. Smallest change; fixes #609; direction stays a bolt-on.
  • O2 — capability family as flat keys. path_read/path_write join path + external_directory as 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, later net), classified by structural proof, a curated command-effects knowledge base (consolidating PATTERN_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

  1. Policy axis: actor-keyed (status quo), +capability layer, or effect-primary.
  2. Composition semantics: retain the most-restrictive lattice, or simplify (O6-style ordering, or a narrower layer set).
  3. Key naming and shape: path_read/path_write vs fs-prefixed vs nesting sugar.
  4. Unknown-effects stance: inert-when-absent vs an explicit unknown category in policy.
  5. Defaults and breaking posture for redirect writes (leaning: shipped ask default, feat!: — reopened).
  6. Whether the command-effects knowledge base becomes a named, curated asset (and when).
  7. 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 17), staging, and consequences. The 0009 slot is next (00010008 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, the path-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_user decision gates before ADR authoring, and the plan marks every leaning as reopened.
  • Cross-doc consistency. If the decision revises composition semantics or the path family, 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, path family descriptions, aspirational policy prose).
  • Current-behavior docs stay true. README.md and docs/configuration.md describe 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.

  1. Survey prior art. Research the nine systems in scope (web sources with citations; librarian/fetch_content for 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.
  2. Deliberate and settle. Present the survey findings and the O1O6 evaluations against the criteria; run ask_user gates covering the seven open parameters (clustered: axis+composition; naming+shape; unknowns+defaults; knowledge base+staging). No commit — decisions precede prose.
  3. Author ADR 0009. Write docs/decisions/0009-permission-policy-model.md recording the threat model, criteria, survey, options with rejected alternatives, the settled decisions, staging for #609, and consequences. Verify with pnpm exec rumdl check on the new file. Commit: docs(pi-permission-system): record ADR 0009 deciding the permission policy model (#639).
  4. Reconcile the architecture doc. Link ADR 0009 and reconcile any contradicted prose in docs/architecture/architecture.md in 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-improvements pass) — settled by the ADR's staging section.