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

245 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 639
issue_title: "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.
[#301]: https://github.com/gotgenes/pi-packages/issues/301
[#306]: https://github.com/gotgenes/pi-packages/issues/306
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#452]: https://github.com/gotgenes/pi-packages/issues/452
[#481]: https://github.com/gotgenes/pi-packages/issues/481
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#574]: https://github.com/gotgenes/pi-packages/issues/574
[#575]: https://github.com/gotgenes/pi-packages/issues/575
[#581]: https://github.com/gotgenes/pi-packages/issues/581
[#591]: https://github.com/gotgenes/pi-packages/issues/591
[#596]: https://github.com/gotgenes/pi-packages/issues/596
[#609]: https://github.com/gotgenes/pi-packages/issues/609