feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,92 @@
---
status: accepted
date: 2026-06-12
---
# 0001 — Adopt `ctx.isProjectTrusted()` to guard project-local config loading
## Status
Implemented in #644.
The fix landed the guard in `handleSessionStart` and `handleResourcesDiscover` as decided below, and extended it beyond the originally-scoped permission-policy path: the extension **runtime** config load (`ConfigStore.refresh``loadAndMergeConfigs`, which merges project `yoloMode` / `permissionReviewLog` / …) is gated on trust too, and so is the `before_agent_start` mid-session `refreshConfig`.
A skip is surfaced loudly (UI warning + a `project_trust.skipped` review-log entry).
See `docs/migration/0644-project-trust-gating.md`.
## Context
Pi SDK 0.79.x introduced a project-trust system with three extension-facing APIs:
- **`project_trust` event** — fired before project-local resources are loaded by Pi.
Global/CLI extensions can handle it to decide, remember, or defer trust (`"yes" | "no" | "undecided"`).
- **`ctx.isProjectTrusted()`** — available on the `ExtensionContext` passed to all event handlers, including `session_start`.
Returns the effective trust decision (including any temporary or remembered decision).
- **`defaultProjectTrust` global setting** (0.79.1) — configures whether unresolved trust automatically asks, always trusts, or never trusts.
`pi-permission-system` currently loads project-local permission config in `handleSessionStart`:
```typescript
// handlers/lifecycle.ts
handleSessionStart(event: SessionStartPayload, ctx: ExtensionContext): Promise<void> {
this.session.refreshConfig(ctx); // calls permissionManager.configureForCwd(ctx.cwd)
// ... which calls loader.loadProjectConfig() → reads {cwd}/.pi/settings.json
// loader.loadProjectAgentConfig() → reads {cwd}/.pi/agents/*.md
```
This load happens unconditionally — the extension never queries `ctx.isProjectTrusted()`.
### Trust gap
The `permission-manager` merges config scopes lowest → highest precedence: `global``project``project-agent`.
Because project scope has higher precedence than global, a malicious `.pi/settings.json` in an untrusted repository could set patterns such as `"*": "allow"` and override the operator's global restrictions.
If a developer opens Pi in a checked-out directory from an untrusted source, the project permission config is loaded and applied without any trust gate.
This is inconsistent with Pi's own trust model: Pi uses `project_trust` to decide whether to load project-local skills, prompts, and agents.
`pi-permission-system` has its own read path to the same directory and bypasses that decision.
### Timing
The `project_trust` event fires before `session_start`.
By the time `handleSessionStart` is called, `ctx.isProjectTrusted()` already reflects the resolved decision — including any `defaultProjectTrust` override.
If the user grants trust after initial load, Pi fires `resources_discover` with `reason: "reload"`, which `handleResourcesDiscover` already handles by calling `session.reload()`.
This means the fix does not require a new event handler.
## Decision
**Adopt `ctx.isProjectTrusted()` in `handleSessionStart` and `handleResourcesDiscover`.**
When the project is not trusted, skip loading project-scoped permission config (project and project-agent layers).
The reload path already re-calls `configureForCwd`, so trust granted after startup picks up the project config on the next `resources_discover reload` cycle.
This is a behavior change: users who open Pi in an untrusted directory will see only global permission config until they grant trust.
The implementation is straightforward but must:
1. Guard `configureForCwd` / project-layer loading in `handleSessionStart` with `ctx.isProjectTrusted()`.
2. Verify the existing `handleResourcesDiscover` reload path picks up project config after trust is granted.
3. Add tests for the untrusted-project load path and the trust-grant reload.
4. Include a changelog entry that describes the behavior change.
No changes to the `project_trust` event handler are needed: `pi-permission-system` has no opinion about *how* trust is resolved (that is Pi's and the user's concern).
It only needs to *observe* the outcome via `ctx.isProjectTrusted()`.
## Implementation scope
Implemented in #644 with its own TDD cycles and a migration note (`docs/migration/0644-project-trust-gating.md`).
## Alternatives considered
**Listen to `project_trust` and decide trust actively.**
`pi-permission-system` could register a `project_trust` handler and return `"yes"` or `"no"` based on its own heuristics (e.g., whether a `.pi/settings.json` exists).
Rejected: the extension is a policy enforcer, not a trust oracle.
Deciding trust is Pi's and the user's responsibility.
The extension should observe the decision, not make it.
**Load project config unconditionally, sanitize the merge to be restrict-only.**
Change the merge to enforce that project config can only tighten global restrictions, not expand them.
Rejected: the merge semantics are intentional — operators who want project-specific overrides in a trusted directory should be able to set them.
Sanitizing the merge would break the override use case.
The right fix is to gate loading on trust, not constrain the merge model.
**No change — current behavior is acceptable.**
Rejected: the trust gap is real.
An untrusted project can expand permissions above the global baseline.
Even though this requires the operator to actively open Pi in a malicious directory, aligning with Pi's own trust system is the correct direction now that the SDK provides the API.
@@ -0,0 +1,85 @@
---
status: accepted
date: 2026-06-30
---
# 0002 — Keep `path-values` as the manager's string boundary
## Status
Accepted.
## Context
Phase 7 Steps 1 and 2 ([#502], [#503]) routed every path-shaped surface onto the `AccessPath` value object: the per-tool path gate, the cross-cutting `path` and `external_directory` gates, and the service/RPC policy queries all emit an `access-path` `AccessIntent`.
The resolver unwraps that intent via `AccessPath.matchValues()` into a string-based `path-values` intent before the low-level manager evaluates rules.
This left the resolver as the **sole producer** of the `path-values` variant.
The [#487] vision ("adopt `AccessPath` as the universal internal path representation") listed "collapse the `path-values` variant" as a goal — but the residual variant is not transitional scaffolding.
It is the seam between the path-aware resolver and the deliberately string-based manager, so its fate is a design decision, not a mechanical cleanup.
### The three actors
The resolve path runs through three collaborators in a strict path-awareness gradient:
| Actor | File | `AccessPath`-aware? | Job |
| -------- | ---------------------------- | --------------------------------------------- | ----------------------------------------------------------- |
| Gate | `src/handlers/gates/*.ts` | Yes — builds it via `normalizer.forPath(...)` | Turn a tool call into an `AccessIntent` |
| Resolver | `src/permission-resolver.ts` | Yes — calls `matchValues()` | Compose session rules; unwrap `access-path``path-values` |
| Manager | `src/permission-manager.ts` | **No** — string-based | Evaluate `(surface, string[])` against the ruleset |
### The type split is load-bearing
Two distinct discriminated unions encode the seam, with exactly one converter between them:
- `AccessIntent = ToolAccessIntent | AccessPathAccessIntent` — what a gate emits.
- `ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent` — what the manager consumes.
`PermissionResolver.toResolvedIntent` is the **only** function that converts `access-path``path-values`, calling `path.matchValues()` exactly once.
Its JSDoc already states the intent: "Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`, so the low-level manager never imports the value object." `PermissionManager.check` consumes `ResolvedAccessIntent` and has zero imports from `access-intent/access-path` — the manager's entire path contract is a `string[]` plus a surface name.
## Decision
**Formalize the boundary: keep `path-values` as the manager's intentional string seam.**
The invariant, stated as a three-part contract:
1. The resolver is the **sole** `matchValues()` unwrap site (`toResolvedIntent`), so the lexical canonical alias set ([#418]) is derived once, centrally.
2. The manager is **string-based**: `check()` consumes `ResolvedAccessIntent` (`tool | path-values`) and never imports `AccessPath`.
3. Path-awareness flows downward and **stops at the resolver** — the manager is a leaf with no `access-intent/access-path` dependency.
To keep the invariant from eroding silently, an ESLint `no-restricted-imports` rule scoped to `permission-manager.ts` forbids importing `access-intent/access-path`, mirroring the existing `process.platform` `no-restricted-syntax` guard ([#510]).
Collapsing the boundary would then require an explicit, reviewed lint exception rather than an unremarked import.
This decision is non-breaking: no runtime behavior changes, no public type changes, no config changes.
## Consequences
- The manager stays a string-matching leaf with a single responsibility — evaluate `(surface, string[])` against a ruleset — and no path semantics.
- `matchValues()` keeps a single call site, so the [#418] lexical canonical alias derivation stays central.
- The lint guard pins "the manager never imports `AccessPath`" deterministically, verified in CI via `pnpm run lint`.
- Phase 7 Step 5 ([#506]) is the last open step; with Steps 14 already shipped, this closes Phase 7.
## Alternatives considered
**Collapse the variant — move the `matchValues()` unwrap into the manager.**
The manager's `check()` would accept the `access-path` variant directly and call `matchValues()` itself, deleting `PathValuesAccessIntent` / `ResolvedAccessIntent` and importing `AccessPath`.
Rejected on three grounds:
- **Single responsibility.**
The manager evaluates `(surface, string[])` against a ruleset — a complete, testable contract with no path semantics.
Collapsing grows the engine a second concern (path representation) it currently delegates away.
- **Tell-Don't-Ask wash.**
Collapse does not remove the `matchValues()` ask; it relocates the single unwrap one layer deeper, into the busier string-matching engine.
- **Dependency direction.**
Collapse widens the manager (a leaf) with an `AccessPath` import to save one nominal type (`PathValuesAccessIntent`) and one converter — removing a real seam for a nominal gain.
The entire upside of collapse is one fewer named type and one fewer converter; the cost is the manager's lost string-engine invariant and a wider dependency surface.
By the "structural reasons before extracting" and ISP heuristics, that is the wrong trade.
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#487]: https://github.com/gotgenes/pi-packages/issues/487
[#502]: https://github.com/gotgenes/pi-packages/issues/502
[#503]: https://github.com/gotgenes/pi-packages/issues/503
[#506]: https://github.com/gotgenes/pi-packages/issues/506
[#510]: https://github.com/gotgenes/pi-packages/issues/510
@@ -0,0 +1,61 @@
---
status: accepted
date: 2026-07-04
---
# 0003 — Interpret POSIX-shaped bash tokens with Git Bash semantics on win32
## Status
Accepted.
## Context
On Windows, Pi core executes every bash tool command through Git Bash (`pi/packages/coding-agent/src/utils/shell.ts` resolves the shell as custom `shellPath``%ProgramFiles%\Git\bin\bash.exe` → any `bash.exe` on PATH; there is no cmd/PowerShell branch).
A bash token that looks like a POSIX absolute path therefore carries MSYS mount semantics, not native `node:path.win32` semantics.
Before this decision, the permission system normalized every bash token with `node:path.win32`, reinterpreting POSIX-shaped tokens as native Windows paths the shell never touches ([#533]):
- `/dev/null` became `c:\dev\null`, so the safe-device exclusion never matched and `echo hi > /dev/null` prompted — even though Pi core itself rewrites `> NUL` to `> /dev/null` before spawning Git Bash (`normalizeNulRedirects()`, [earendil-works/pi#4731]).
- `/tmp` became `C:\tmp`, so prompts displayed a fabricated path and a rule for the real `C:\tmp` could cross-match a Git Bash `/tmp` token.
- `/c/Users/x` became `C:\c\Users\x`, so a project file referenced through the MSYS drive mount was wrongly flagged external.
This contradicted the package's own documented contract that OS device paths are always excluded.
## Decision
On a win32 host, the **bash surface's** path semantics are MSYS, not win32.
The bash token pipeline classifies each POSIX-shaped absolute token (`msys-bash-tokens.ts`, consumed only by `PathNormalizer`) into a deterministic subset and interprets it accordingly:
| Token shape | Interpretation |
| ------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `/dev/null`, `/dev/std{in,out,err}` (exact) | MSYS device — preserved verbatim, never external (matches the POSIX exclusion) |
| `/c/…`, `/d/…` (drive mount) | Translated to the Windows equivalent (`C:\…`), then resolved with win32 rules |
| Other `/…` (POSIX absolute) | Literal-only external path — matched and displayed as typed, never fabricated into `C:\tmp` |
| `C:\…`, `C:/…`, relative, `~/…` | Unchanged native win32 handling ([#508], [#382]) |
Tool-input paths (`read`/`write`/`edit`) keep native win32 semantics: Node's `fs` genuinely resolves `/dev/null` to `C:\dev\null` on Windows, so prompting for a tool-input `/dev/null` is correct (least privilege).
### Rejected alternatives
- **`cygpath` shell-outs / MSYS environment detection.**
Rejected: non-deterministic (depends on which bash Pi core resolved and the ambient environment), slow, and it breaks the invariant that the same policy plus the same input always produces the same decision.
- **Mapping `/tmp` to `%TEMP%` / `os.tmpdir()`.**
Rejected: the target varies by bash flavor (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root, Cygwin to another), so any concrete mapping is wrong for some installs and reads ambient host state.
A literal-only external path is the honest deterministic treatment.
## Consequences
- Non-mount POSIX absolutes are always external on win32 and cannot resolve inside the working directory, so they always reach the `external_directory` gate — conservative by design.
- A win32 POSIX-absolute literal is matched and displayed exactly as typed (`/tmp/foo`), so a natural `external_directory` rule (`/tmp/*`) suppresses the prompt.
This relies on the win32 path matcher folding separators on **both** the rule and the value ([#653]).
It originally carried a backslash match alias (`\tmp\foo`) instead, because the fold reached only the rule pattern; that alias was removed once the fold became symmetric.
- `PathNormalizer` owns the win32/MSYS branching (`forBashToken`, `interpretBashCdTarget`, `isBoundaryOutsideWorkingDirectory`); the shape knowledge lives in the pure, separately-tested `msys-bash-tokens.ts`.
- The `PermissionsService` RPC path-query surface is unchanged: an external query for a POSIX-shaped path on win32 still answers with win32 semantics, since a path query carries no bash-surface context.
This is an accepted inconsistency, to revisit only if a consumer reports it.
[#382]: https://github.com/gotgenes/pi-packages/issues/382
[#508]: https://github.com/gotgenes/pi-packages/issues/508
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#653]: https://github.com/gotgenes/pi-packages/issues/653
[earendil-works/pi#4731]: https://github.com/earendil-works/pi/issues/4731
@@ -0,0 +1,53 @@
---
status: accepted
date: 2026-07-06
---
# 0004 — Zod as the single source of truth for config schema and validation
## Status
Accepted.
## Context
Issue [#547] asked for a hosted JSON Schema so editors give completions and flag typos in the permission-system config.
A schema already existed (`schemas/permissions.schema.json`), the config already accepted a `$schema` key, and the example config already set it — but two defects remained:
- Every hosted URL (`$id`, the example config's `$schema`, doc references) pointed at `raw.githubusercontent.com/gotgenes/pi-permission-system/…`, the pre-monorepo upstream fork, not the monorepo path.
- The JSON Schema was hand-maintained separately from the TypeScript types and the hand-rolled loader guards (`value-guards.ts`, `normalizeUnifiedConfig`), so the three could drift — the exact maintenance trap the package skill warns about.
The loader was also **tolerant**: it silently discarded a malformed field (a non-boolean `debugLog`, an invalid permission action, an unknown key) and loaded the rest, so a typo failed quietly.
## Decision
Adopt **zod** (`^4.4.3`) as the single source of truth for the config-file shape (`src/config-schema.ts`):
- Composable schemas (`permissionState``denyWithReason``patternValue``permissionMap``permission` → the unified config) mirror the previous `$defs` structure.
The config types (`PermissionState`, `DenyWithReason`, `PatternValue`, `FlatPermissionConfig`, `UnifiedPermissionConfig`) are derived with `z.infer` and re-exported from `types.ts` / `config-loader.ts`, so there is one definition, not three.
- The published `schemas/permissions.schema.json` is **generated** from the zod source via `z.toJSONSchema` (Draft 2020-12) by `pnpm run gen:schema`; a parity test fails if the committed file drifts.
The root `$id` and every doc/example `$schema` URL now point at the monorepo raw path.
- The config-file loader validates via `unifiedConfigSchema.safeParse`.
Rich editor metadata (`markdownDescription`, `examples`, per-value descriptions, `default` annotations) is carried through zod's `.meta()`.
Validation is **strict and fail-closed** (breaking): a config file with any invalid field is rejected as a whole scope — it contributes an empty config, so missing surfaces fall through to the universal `ask` default rather than `allow` — and every violation is reported as a clear, path-qualified issue.
### Scope boundaries
- **Per-agent frontmatter is not validated by this schema.**
Agent `.md` frontmatter carries non-config keys (`name`, `description`, `model`, …) alongside a `permission:` block; routing it through the strict `strictObject` would reject those keys.
`policy-loader.ts` therefore extracts and tolerantly normalizes only the `permission` block, unchanged.
- **Legacy files keep their migration guidance without strict-validation noise.**
The move-it message is the actionable signal; the loader suppresses zod issues for legacy paths.
- **The flat-permission-to-`Rule` translation (`normalize.ts`, `policy-loader.ts`) is unchanged** — it consumes already-validated config and keeps using `isPermissionState` / `isDenyWithReason`.
## Consequences
- One edit point for the config shape; the schema, types, and runtime validator cannot drift.
- Typos and wrong-typed fields are caught — in the editor (via `additionalProperties: false` + types) and at load time (with a clear message) — instead of failing silently.
- **Breaking:** a config that previously loaded with silently-dropped fields is now rejected until the reported problems are fixed; the affected scope falls back to `ask` until then (see `docs/migration/strict-config-validation.md`).
- A new runtime dependency (`zod`) is added.
- The config-only guards `normalizeOptionalStringArray` and `normalizeOptionalPositiveInt` were removed (superseded by zod), shrinking the scope of the still-open [#532].
[#532]: https://github.com/gotgenes/pi-packages/issues/532
[#547]: https://github.com/gotgenes/pi-packages/issues/547
@@ -0,0 +1,72 @@
---
status: accepted
date: 2026-07-09
---
# 0005 — Serving a forwarded permission is resolution; provenance rides the ask
## Status
Accepted.
## Context
A subagent with no UI escalates an `ask` decision up the tree by writing a forwarded-permission request file; the parent (serving) session drains its inbox and answers each request.
Before this decision, the serving node answered with bespoke logic — its own yolo check (the last one outside the composed ruleset) and a direct UI dialog — and never ran `evaluate()`.
So a parent `allow`/`deny` rule could not govern a child's escalation: the parent was prompted for actions its own policy had already decided ([#557], Phase 9 resolved direction 1).
Rerouting the prompt naively through the serving session's selected `Authorizer` (`LocalUserAuthorizer`) would have silently re-degraded the `permissions:ui_prompt` broadcast to `forwarding: null`, reversing the deliberate [#292] contract hardening (`docs/cross-extension-api.md`: "Forwarded prompts are not degraded"), which no in-monorepo consumer exercises and a green test suite would not catch.
## Decision
Serving a forwarded request is identical to resolving a local action:
1. **Recorded authority first.**
A request carrying a concrete `(surface, value)` display projection resolves against the serving node's composed ruleset via a narrow `ServingPolicy.check(surface, value)` seam (an access-intent build plus `resolver.resolve`, the same primitives `LocalPermissionsService` composes).
`allow` (including a yolo-rewritten `allow`) auto-approves; `deny` auto-denies.
2. **Escalate `ask`.**
An `ask`, or a request without display fields, escalates through the `AskEscalator` seam to the serving session's selected `Authorizer` — the human at the root today, a further hop up once multi-hop lands.
3. **Provenance is data on the ask, not a second emission path.**
The escalated ask carries its forwarded provenance (requester agent/session, the child's original `source`/`surface`/`value`) as fields on `PromptPermissionDetails`.
`LocalUserAuthorizer` — now the single `permissions:ui_prompt` emit site — renders it (populated `forwarding` context, the child's display projection, the "(Subagent)" dialog title), so the broadcast stays non-degraded ([#292]) with no server-side emission.
The serving node's yolo check and its `ConfigReader` dependency are removed; yolo inheritance falls out of the yolo-rewritten ruleset for free (a yolo `ask``allow` rewrite auto-approves at step 1, an explicit `deny` survives it).
### Base ruleset (agent-neutral resolution)
`ServingPolicy.check` resolves with `agentName` undefined — the serving node's own base policy.
The requesting subagent's agent name is display-only.
Rationale: the child already applied its own per-agent overrides before forwarding, and cross-session agent-name semantics are undefined until principal identity lands in the access-intent domain.
Revisited post-ship in [#565].
### Rejected alternatives
- **Server-side event emission with a decision-only `Authorizer` call.**
Rejected: it splits the emit-then-dialog pairing `LocalUserAuthorizer` owns, needs an emit-suppressed `authorize` variant (a genuine control flag), and keeps two `permissions:ui_prompt` emit sites that can drift.
- **A per-request decorator `Authorizer` that adds forwarding presentation.**
Rejected: authorizers are selected once per session; a per-request decorator is the same data flow dressed in object ceremony.
- **Route `ask` through the `Authorizer` and accept the degraded broadcast.**
Rejected: it reverses the [#292] hardening for the exact consumer (notification extensions) it was built for, undocumented as a considered trade-off.
## Consequences
- Parent `allow`/`deny` rules govern children's escalations; a recorded `allow` suppresses the prompt, a recorded `deny` auto-denies.
- An explicit `deny` now wins under yolo on the serving path (previously the bespoke yolo check approved everything), matching documented yolo semantics.
- A legacy/version-skew request without `(surface, value)` escalates to a prompt instead of auto-approving under yolo — the fail-safe direction; the fields have been carried since [#292].
- A request the recorded policy decides emits no `permissions:ui_prompt`; the event fires only when a human is about to be asked (the notify-now contract's intent).
- An escalated forwarded ask now also flows through the `PermissionPrompter` bracketing (`permission_request.waiting`/`approved`/`denied`) alongside the serving lifecycle's `forwarded_permission.*` entries — the uniform-escalation shape [#556] chose.
### Accepted limitations
- **Single-surface re-resolution is best-effort.**
A forwarded request carries one `(surface, value)` pair, so the serving node cannot perfectly reproduce a child decision that layered multiple surfaces (e.g. an `external_directory` check over a `path`).
An imperfect resolution lands on `ask` → prompt, never a silent grant.
- **No real notification consumer exercises the non-degraded broadcast in-repo.**
The [#292] fidelity is pinned by unit tests (the `LocalUserAuthorizer` forwarded-details render plus the server's details mapping) but not an end-to-end consumer.
Post-ship validation of all three — base-agent-scope, single-surface fidelity, and real-consumer fidelity — is tracked in [#565].
[#292]: https://github.com/gotgenes/pi-packages/issues/292
[#556]: https://github.com/gotgenes/pi-packages/issues/556
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#565]: https://github.com/gotgenes/pi-packages/issues/565
@@ -0,0 +1,74 @@
---
status: accepted
date: 2026-07-09
---
# 0006 — Grant-scope selection on forwarded approvals
## Status
Accepted.
## Context
A subagent with no UI escalates an `ask` up the tree; the serving (parent/root) session drains its inbox and a human decides ([#557], ADR-0005).
When that human approves "for this session," the ruling could land only on the **requesting subagent**: the response rides back to the child, whose `GateRunner` records the pattern into the child's own `SessionRules`.
The human had no way to record the ruling at the **serving scope**, so a grant meant to cover the parent and all its subagents had to be re-approved per child ([resolved direction](../architecture/architecture.md#resolved-direction) 4, Phase 9 Step 4).
## Decision
Offer the human a scope when approving a forwarded request "for this session," and record a whole-session grant on the serving node.
1. **The child rides its suggestion along.**
`GateRunner` already computes a `SessionApproval` (surface + one-or-more patterns) for the ask.
It now flows on `PromptPermissionDetails.sessionApproval` (via `SessionApproval.toForwardedData()`) into the `ForwardedPermissionRequest.sessionApproval` field the child writes.
The field is optional and read tolerantly, so an older child (no suggestion) simply offers no scope choice.
2. **A two-step dialog.**
The base four-option prompt is unchanged.
Choosing "for this session" opens a second `select` — subagent-only (listed first, the least-privilege default) vs the whole session — but only for a forwarded ask that carries a suggestion.
A cancelled scope select defaults to subagent-only.
`LocalUserAuthorizer` builds the scope labels (`buildForwardedScopeLabels`) and is still the single `permissions:ui_prompt` emit site; the emit fires once before the first select, so the [#292] non-degraded broadcast is unaffected.
3. **Whole-session grants record on the serving node only.**
The dialog returns a serving-node-internal `approved_for_serving_session` state.
`ForwardedRequestServer.applyGrantScope` records the child's suggested pattern into the serving session's `SessionRules` — the same instance the resolver and gate runner read — then translates the response to a plain `approved`.
The child records nothing; its next identical action re-forwards and resolves as recorded authority (the [#557] serve-time evaluation auto-approves it).
A subagent-only grant (`approved_for_session`) passes through untouched — the child records, exactly as before.
The serving node is the single source of truth for a whole-session grant.
Because the serving `SessionRules` is shared, the grant governs the parent's own actions immediately and future forwarded resolutions for free.
### The `approved_for_serving_session` state
It is serving-node-internal: produced by the dialog, consumed by `ForwardedRequestServer`, and translated to `approved` before any response is written, so it never reaches disk or the child.
It is a member of `PermissionDecisionState` (and `isPermissionDecisionState`, for guard completeness); the on-disk `ForwardedPermissionResponse.state` stays within the four legacy values.
### Rejected alternatives
- **Record on both the serving node and the requesting child.**
Rejected: two copies blur the scope, and the subagent-only vs whole-session distinction collapses to "does the parent also hold a copy."
Serving-node-only keeps a single source of truth; the child re-forwards and auto-approves.
- **A `grantScope` marker on an `approved_for_session` decision.**
Rejected: the server must translate the response to `approved` for the whole-session case anyway (so the child does not double-record), and a state that says "subagent" while a marker says "serving" is less honest than a distinct state.
- **Inline scope options in the base dialog (a five-option prompt).**
Rejected in favor of the operator's two-step choice: the base prompt stays byte-identical for every local ask, and the scope question appears only when it applies.
## Consequences
- A human can grant a forwarded request for the whole serving session; the parent and its subagents then resolve it without a second prompt.
- The default (subagent-only, pre-selected) preserves today's behavior exactly; this ships as `feat:`, not a breaking change.
- The forwarded request and response formats gain one optional field each, read tolerantly — an upgrade needs no config edit and tolerates version skew.
### Accepted limitations
- **Cross-cwd / cross-surface re-resolution is best-effort.**
A recorded whole-session path grant matches a child's later forward only when cwd and surface align — the pre-existing single-surface/cross-cwd limitation from ADR-0005 (`docs/decisions/0005-serving-authorizer-provenance.md`), tracked in [#565].
An imperfect match lands on `ask` → prompt, never a silent grant.
- **Three-way scope (root / parent / requesting subagent) is not shipped.**
The tree is depth-2 today, so "parent" and "root" coincide and the dialog offers two scopes.
The three-way split waits on multi-hop escalation — admitted-not-shipped, the same shape as the escalation chain.
[#292]: https://github.com/gotgenes/pi-packages/issues/292
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#565]: https://github.com/gotgenes/pi-packages/issues/565
@@ -0,0 +1,254 @@
---
status: accepted
date: 2026-07-15
---
# 0007 — Model-assisted permission judge as an Authorizer chain
## Status
Accepted.
This decision settles the design of a case-by-case model judge ([#472]); it does not implement it.
[#472] stays open, tracking the implementation, and carries this ADR.
It supersedes the reverted ADR 0007 attempted under [#581].
Amended 2026-08-14 with §7 (one chain per node), which settles where a subagent's ask is adjudicated ([#727]).
## Context
`yoloMode` is the only non-static path in a permission decision today: a single boolean that rewrites every `ask` rule to `allow` at composition time (`origin: "yolo"`), suppressing prompts while preserving hard denies.
It is all-or-nothing — it cannot approve one clearly safe `ask` and still prompt on the rest.
[#472] asks for a case-by-case judge: a light model (e.g. Claude Haiku) that participates in a decision, deciding per ask.
It was deferred by name in Phases 9 and 10.
[#581] then attempted a decision record but treated it as transcription of the architecture doc's settled `ModelTriageAuthorizer` prose — an ask-only, allow-or-escalate decorator — and that ADR was found premature and reverted.
An ADR's value is the deliberation behind it; the prose itself was the wrong input.
Two concrete use cases, surfaced during the [#581] retro, show the real design is broader than — and in one respect contradicts — the reverted prose:
1. **Reject errant "typo" paths automatically.**
Models frequently invoke tools against a malformed path (e.g. `…/pi-permission-system/packages/pi-permission-system/src/x.ts`, where the first segment should be `pi-packages`).
These land as `external_directory` asks that a human hand-denies one by one.
A light model should review such asks, *defer* the ones that do not match a configured typo pattern, and *deny* the ones that do — returning a teaching reason (wrong path; correct location) so the invoking model self-corrects.
2. **Adjudicate opaque bash commands.**
Commands the deterministic parser cannot decompose (`bash -c "…"`, `eval`, unparseable constructs) floor to `ask` via sentinels.
A model should decompose the opaque command, query the deterministic engine per sub-command, and *allow* if clearly fine, *deny* if it hits a denied path, or *defer* if it cannot decide confidently.
The enabling premise is that nothing constrains an `Authorizer` to be deterministic.
`LocalUserAuthorizer` is already a non-deterministic oracle — the human — and the determinism principle governs *recorded* authority (`evaluate()`), never the live-authority layer (ADR `docs/decisions/0005-serving-authorizer-provenance.md`).
A model can hold the `Authorizer` role on the same terms.
## Decision
Model the live-authority layer as a **Chain of Responsibility**, and place the model judge in it as a non-terminal link.
### 1. Verdict range is `allow | deny | defer`
Each link either decides (`allow` / `deny`) or defers to the next link.
This is a superset of the reverted ADR's ask-only allow-or-escalate framing: use case 1 is deny-first, and an `Authorizer` already denies (the human does; `DenyingAuthorizer` always does), so a model in that role can deny an ask too.
A `deny` carries an optional `reason` — the teaching signal use case 1 needs.
```typescript
type AuthorizerVerdict =
| { kind: "allow" }
| { kind: "deny"; reason?: string }
| { kind: "defer" };
```
### 2. The terminal link cannot defer
The chain ends at a terminal that must decide; until it does, the system pauses.
Today that terminal is the human (`LocalUserAuthorizer`), the headless `DenyingAuthorizer`, or `ParentAuthorizer` (terminal *for its node* — it forwards up and returns the parent node's `allow | deny`, the multi-hop recursion).
The invariant is enforced **at the type level**, not by a runtime assertion: a terminal returns only `allow | deny`, so a link that could defer cannot occupy the terminal slot.
```typescript
/** A non-terminal chain link: may decide or defer. */
interface Authorizer {
authorize(details: PromptPermissionDetails, query: PermissionQuery, log: AuthorizerLog): Promise<AuthorizerVerdict>;
}
/** The terminal link: structurally cannot defer. */
interface TerminalAuthorizer {
authorize(details: PromptPermissionDetails, query: PermissionQuery): Promise<TerminalVerdict>;
}
// TerminalVerdict = { kind: "allow" } | { kind: "deny"; reason?: string }
```
`selectAuthorizer` (which returns a single `Authorizer` today) generalizes to `composeAuthorizerChain`: registered non-terminal links, then the context-selected terminal last.
The terminal selection is unchanged.
### 3. The query capability is injected, not imported
A link never reaches for the cross-extension `PermissionsService` via `Symbol.for()` (a Law-of-Demeter reach-through to a global).
The chain injects a narrow, session-scoped `PermissionQuery` into each link at `authorize` time — a projection limited to what a link needs (ISP), backed by the same resolver the gates use so it answers at gate parity.
```typescript
/** Narrow, injected projection of PermissionsService. */
interface PermissionQuery {
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
getToolPermission(toolName: string, agentName?: string): PermissionState;
}
```
The tool-augmented adjudication (use case 2) exposes these primitives to the model *as tools*: the model decomposes an opaque command and calls `checkPermission("bash", subCommand)` / `checkPermission("external_directory", token)` per piece; the deterministic engine answers every sub-question.
The model's non-determinism is confined to *how it decomposes*, never *what the rules decide* — determinism-of-decision survives at the leaf.
#### The review-log seam is injected the same way
A link is handed a second narrow capability at `authorize` time: an `AuthorizerLog`, for recording its decision trail.
The motivation is observability — without it a link's verdict (especially a `defer`) is unobservable, so a misbehaving link deferring every ask is indistinguishable from a link never running (the `pi-permission-model-judge` auth-failure that motivated this addition).
The seam follows the same injection discipline as `PermissionQuery`: a link never reaches for the session logger via `Symbol.for()`; the chain owner passes the session's own logger straight through, so a link's entries land in the same `pi-permission-system-permission-review.jsonl` as the gate decisions, keyed by `requestId`.
```typescript
/** Narrow, injected review-log seam. */
interface AuthorizerLog {
review(event: string, details?: Record<string, unknown>): void; // durable, default-on audit entry
debug(event: string, details?: Record<string, unknown>): void; // verbose detail, gated by `debugLog`
}
```
The seam only *records*; it grants no authority and cannot alter a verdict, so it is inert with respect to the bounded-delegation invariant below.
### 4. Named-capability registration, opt-in activation
Registration mirrors `registerToolAccessExtractor`: a downstream extension offers a **named** capability on the published service.
```typescript
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
```
The downstream extension registers in a `permissions:ready` handler, so registration is robust to load order and survives `/reload`; it must land before the session's first ask.
Composition then reads the operator's configured chain and binds names to registered capabilities.
Three invariants govern the seam:
1. **Config order wins, never registration order.**
Chain order is security-relevant (an allow-capable link ahead of a deny-capable one changes outcomes), so it is deterministic operator policy — never a function of nondeterministic extension load order.
2. **Skipping any non-terminal link is always fail-safe.**
A missing or unregistered configured name removes only allow/deny *shortcuts*; the ask still reaches the terminal.
Absence of a judge means *more* prompting, never less — so a missing name is skipped with a warning.
3. **Registration alone grants no authority.**
A registered link decides nothing until the operator names it in the `authorizerChain` config — the opt-in activation model.
Installing a judge extension does not silently hand it decision authority.
### 5. Config split: policy here, mechanism downstream
Two independent extension config files, joined only by the link name — no merged schema.
This package declares and *enforces* the safety policy; the downstream extension declares and *uses* the model mechanism.
```jsonc
// pi-permission-system config.json — operator-owned policy (read + enforced HERE)
{
"authorizerChain": ["model-judge"],
"modelDelegation": {
"allowedSurfaces": ["bash"],
"excludedSurfaces": ["external_directory"] // + secret-shaped path always excluded
}
}
```
```jsonc
// pi-permission-model-judge config.json — downstream-owned mechanism (read THERE)
{ "provider": "anthropic", "model": "claude-haiku-…", "instructions": "…", "timeoutMs": 5000 }
```
The bounded-delegation policy is enforced at an **enforcement checkpoint** the chain owner (this package) applies to every verdict: a link's `allow` on an excluded surface is downgraded to `defer`.
So the safety envelope lives where it is enforced, and a buggy or over-eager external judge can never exceed the operator's policy.
This package holds no model-prompt config it does not read (the "declared-but-unread config is a maintenance trap" priority).
### 6. Two slices, a capability gradient
Both use cases are the *same* judge link; they differ only by which verdicts are enabled and how much envelope guards them.
| Aspect | Slice 1 — deny-first reviewer (use case 1) | Slice 2 — allow-capable adjudicator (use case 2) |
| ------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Verdicts | `deny`, `defer` | `+ allow` |
| Risk | strictly more restrictive — always safe | loosens privilege — needs full envelope |
| Envelope | fail-closed only (unreachable/uncertain → defer) | + hard exclusions, audit `origin:"authorizer:model"`, non-persistence, off by default, bounded-delegation ruleset |
| Failure mode | a wrong deny — recoverable (agent self-corrects on the reason) | decomposition infidelity — mis-split obfuscation (`bash -c "safe; evil"`) allowed on the safe piece alone |
The "a tool-augmented model can never grant more than the engine grants for the pieces it identifies" safety property holds *only if decomposition is faithful*.
Obfuscation is the residual risk, and it is exactly why slice 2 is gated behind the whole envelope while slice 1 needs almost none.
The gradient is the argument for shipping deny-first.
### 7. One chain per node
An ask is adjudicated by exactly one node's chain: the node whose terminal decides it.
A node with UI (`LocalUserAuthorizer`) and a headless node with no reachable authority (`DenyingAuthorizer`) both decide locally, so both run their chain.
A subagent node whose terminal is `ParentAuthorizer` does not decide — it relays the ask to a serving node, which resolves it against its own recorded authority and escalates it through its own chain over the same child-fixed facts ([#635]).
So a relaying node resolves no links, and its terminal's forwarding *is* how the ask reaches a chain.
This is a consequence of §2's terminal-for-its-node framing, made explicit because the observable behavior contradicted it: a child's chain resolution found no links (a child cannot host one — [#699]) and reported each configured name as a fail-safe skip, which reads as the configured judge never running.
It runs; it runs one hop up.
The rule is not merely descriptive of the current implementation.
Running links on a relaying node as well would adjudicate one ask twice — the same link, over the same facts, once in the child and again on the serving node — with a second model call and a second latency, and would let a link decide an ask the serving node's policy owns.
Two review-log records make the placement observable, since a deferring link decides nothing and otherwise leaves no evidence it was consulted:
- `authorizer_chain_resolved` — an adjudicating node's resolved link names, recorded before any of them runs.
- `authorizer_chain_delegated` — a relaying node's configured names, recorded as deliberately not run.
This leaves `authorizer_chain_unregistered_link` meaning what it says: a name the adjudicating node could not resolve, which is a real misconfiguration.
The rule also settles the shape of [#699]'s fix: a sibling extension should skip registering a link in a registered child rather than registering one that would never be consulted.
### Relationship to `evaluate()` and rule-driven promotion
The judge sits on the ask-*consuming* side of `evaluate()`, distinct from the ask-*producing* side (rule-driven promotion, [#509]).
Rule-driven promotion produces the fail-safe false-positive ask (`git grep id_rsa` prompts); the judge dismisses it on the consuming side without hard-coding per-command file-argument tables.
The two compose cleanly: a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.
## Consequences
- [#472] carries a linked, settled ADR and becomes schedulable in a future phase on its own merits.
- The `Authorizer` role generalizes from a single per-session selection into a composed chain; `selectAuthorizer` becomes the terminal-selection step of `composeAuthorizerChain`, and the interface gains a `defer` verdict and an injected `PermissionQuery`.
- The chain is the **one** live-authority extensibility seam.
A model judge is a non-terminal link; a future terminal-replacement backend (a chat-bot or remote reviewer *as* the authority) is the same seam's terminal role.
This subsumes the architecture doc's separately-sketched "pluggable escalation seam" — registering a link *is* the seam, not a mechanism beside it.
- The review log gains a fourth grant provenance (`authorizer:model`, slice 2) alongside human, policy, and yolo.
- **Dogfooding is slice 1's acceptance criterion.**
A first-party package in this monorepo (`packages/pi-permission-model-judge`) implements the deny-first typo-path reviewer against the real seam.
This is a design safeguard, not a demo: the [#267] history guard warns that an inbound registration surface nobody consumes goes vacant; a first-party consumer registering `"model-judge"` on day one makes `registerAuthorizer` born consumed, and its own config file exercises the config split end to end.
The concrete issue is filed by the next `/plan-improvements` pass when the phase is scoped.
- No code, config, schema, or default changes in this documentation step.
### Rejected alternatives
- **Ask-only, allow-or-escalate verdict range** (the reverted ADR).
Rejected: use case 1 is deny-first, and an `Authorizer` already denies, so confining a model link to allow-or-escalate cannot express the typo-path reviewer.
- **A single terminal instead of a chain.**
Rejected: the judge fundamentally decides *some* asks and hands the rest to the real authority — it needs a successor.
A chain with a non-deferring terminal models exactly this, and the operator's mental model was a chain, not a decorated singleton.
- **The judge imports `PermissionsService` via `Symbol.for()`.**
Rejected: a Law-of-Demeter reach-through to a global, and it forces the external extension to import two surfaces.
Injecting a narrow `PermissionQuery` gives one import and an ISP-clean contract.
- **Opt-out activation** (a registered link joins the chain automatically; config can only disable it).
Rejected: it lets a loaded extension gain decision authority unless explicitly disabled, and lets load order influence security-relevant chain order.
Opt-in (config names the chain) is least-privilege by construction.
- **A process-global `AuthorizerRegistry`, so a child resolves its parent's links** ([#727]).
Rejected: it converts every deferring ask into two link runs, and lets a link's verdict short-circuit before the serving node ever sees the request — a privilege change dressed as a plumbing fix.
The forwarding round trip is not the cost being avoided; the serving node resolves the request against its own ruleset regardless.
- **The model applies the ruleset itself, or emits a static intent.**
Rejected: the former couples the model to rule semantics; the latter weakens determinism.
Tool-augmented decomposition keeps the model decoupled from rule semantics (a rule edit is honored automatically) and confines its non-determinism to decomposition.
### Accepted limitations
- **Open implementation parameters.**
Model provider, prompt, confidence threshold, and timeout are deliberately left to [#472] and the downstream package — they are tuning and mechanism, not architecture.
- **[#472]'s decomposition is deferred.**
Whether [#472] splits into staged issues (chain infrastructure; deny-first slice; allow-capable slice; the dogfood package) is [#472]'s own planning decision, sequenced by the next `/plan-improvements` pass.
- **Terminal-replacement registration is deferred.**
Registering a backend *as* the terminal authority is the chain seam's other role, built when a real non-subagent backend needs it — not now.
- **The pre-`evaluate()` classifier stays out of scope.**
A model that *classifies* access intent before `evaluate()` feeds *recorded* authority and weakens the "same `(toolName, input)` yields the same ruling" property more subtly than this live-authority judge; it warrants its own decision record (see the architecture doc's "Beyond the target: a non-deterministic access-intent classifier").
[#267]: https://github.com/gotgenes/pi-packages/issues/267
[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#581]: https://github.com/gotgenes/pi-packages/issues/581
[#635]: https://github.com/gotgenes/pi-packages/issues/635
[#699]: https://github.com/gotgenes/pi-packages/issues/699
[#727]: https://github.com/gotgenes/pi-packages/issues/727
@@ -0,0 +1,201 @@
---
status: accepted
date: 2026-07-18
---
# 0008 — Cross-session access intent: the child owns the facts, the parent owns the judgment
## Status
Accepted.
This decision settles the cross-session access-intent contract; it does not implement it.
It is Phase 12 Track A Step 1 ([#595]); Steps 2 ([#596]) and 3 ([#597]) implement the wire and serving changes this record decides.
It revises the "Base ruleset (agent-neutral resolution)" section of `docs/decisions/0005-serving-authorizer-provenance.md` and composes with the Authorizer chain of `docs/decisions/0007-model-judge-authorizer-chain-adr.md`.
## Context
A subagent child with no UI escalates an `ask` decision up the session tree by writing a forwarded-permission request file; the parent (serving) session drains its inbox and answers each request.
`docs/decisions/0005-serving-authorizer-provenance.md` settled that serving a forwarded request *is* resolution: the serving node runs the request against its own composed ruleset (recorded authority first), escalates a residual `ask` through the `AskEscalator` seam, and carries provenance as data on the ask.
But the escalation edge loses the gate's structured product.
`ForwardedPermissionRequest` carries a pre-rendered `message` plus *display-only* `surface`/`value` strings, so the serving node's `ServingPolicy.check(surface, value)` re-derives an intent from that bare string through the **parent's** `PathNormalizer` and cwd.
Two consequences follow, both named in [#565] (items 23) and accepted as failure modes at [#557] ship time pending exactly this spine:
- **Path meaning is re-interpreted at the wrong node.**
A child in a worktree resolves paths against a different root than the parent, so the child's lexical canonical alias set — the [#418]/[#486] match contract — never crosses the wire.
A parent `allow` can silently miss what the child's own gate would have matched, and vice versa.
- **Agent-scope semantics are undefined.**
`ServingPolicy.check` resolves with `agentName` undefined; `requesterAgentName` is display-only, with no decided meaning.
ADR 0005 explicitly deferred this "until principal identity lands in the access-intent domain."
These questions are unanswerable in code because they were never decided.
The value of an ADR is the deliberation behind it, so the decision is the first deliverable — the wire and serving changes ([#596], [#597]) implement this contract rather than deciding it inline.
## Decision
### The principle — the child owns the facts; the parent owns the judgment
A forwarded ask separates cleanly into two parts:
- **Facts** — what is being accessed, in every form the origin gate would recognize, and by whom.
- **Judgment** — what a policy says about those facts.
The contract:
1. Facts are **fixed at the origin child** — computed where the action was requested — and carried unchanged through every hop.
2. Judgment is **exercised anew at each node** against that node's own ruleset.
3. **No node ever re-derives facts.**
A node that receives a forwarded request treats the carried facts as given; it never reconstructs them through its own `PathNormalizer`/cwd.
The four consequences below are derivations of this principle, not independent parameters.
### 1. A path-shaped ask's meaning is fixed at the child
A path's portable meaning is the alias set computed where the path was typed (the child), never re-derived at the parent.
The child ships the `AccessPath`'s `matchValues()` (the absolute cwd-relative canonical alias set) and `boundaryValue()` (the canonical form) as fixed strings.
The parent matches its own ruleset against those fixed values through the ordinary `evaluateAnyValue` evaluator — it does not rebuild an `AccessPath` from a bare string.
This is portable across cwds because `matchValues()` already carries a **cwd-relative alias**.
A child in `/worktree/issue-42` typing `src/foo.ts` ships `{ /worktree/issue-42/src/foo.ts, src/foo.ts, <canonical> }`:
- A **relative** parent rule (`path: { "src/**": allow }`) matches the child's `src/foo.ts` relative alias, so the parent's authority stays relevant across worktrees and differing cwds.
- An **absolute** parent rule (`/main-checkout/src/**`) matches only co-located paths, so a different worktree's file is correctly *not* covered — least privilege.
Canonicalization does not bridge cwds: a git worktree is a real directory, not a symlink, so the canonical form of a worktree path stays under the worktree.
The cwd-relative alias, not canonicalization, is what makes cross-cwd matching work.
### 2. The `ForwardedAccessIntent` wire schema
A required field on the forwarded request carries the child-fixed facts.
This record fixes the field names and semantics; [#596] owns the exact declaration site and serialization mechanics.
```typescript
interface ForwardedAccessIntent {
/** The gate surface the child evaluated: "path", "external_directory", "bash", a tool name, a skill name, or an MCP target. */
surface: string;
/**
* The child-fixed match set. For a path surface: AccessPath.matchValues()
* (absolute cwd-relative canonical), computed at the child. For a
* non-path surface: the already-portable single value (bash command, MCP
* target, skill name) as a one-element array. Strings only.
*/
matchValues: string[];
/** Canonical boundary form (AccessPath.boundaryValue()) for a path surface; null for a non-path surface. */
boundaryValue: string | null;
/** The requester's cwd, for provenance and prompt disclosure — never for parent re-derivation. */
requesterCwd: string;
/** Principal identity: who is requesting. */
principal: {
sessionId: string; // carried today as requesterSessionId
agentName: string; // decision-participating (§3)
};
}
```
The field carries **strings**, never `AccessPath` instances: `docs/decisions/0002-path-values-string-boundary.md` keeps the manager string-based, and the wire honors that boundary.
Non-path surfaces (a bash command pattern, an MCP target, a skill name) are already portable — they carry their single value as a one-element `matchValues` with `boundaryValue: null`.
### 3. Agent-scoped serving evaluation
`requesterAgentName` graduates from display-only to **decision-participating**.
The serving node resolves the forwarded intent against its own base ruleset scoped to the requester's agent name (`principal.agentName`), applying the parent's per-agent overrides for that agent.
This is not double-application.
Forwarding up means the child's ruleset already resolved to `ask` — unresolved — so the child carries no judgment upward.
The parent then applies a **different** ruleset (its own config and project layer).
Agent-scoped serving is a strict superset of the agent-neutral serving it replaces:
- With identical parent and child configs, the parent also lands on `ask` and prompts — no regression.
- It changes the outcome only when the parent holds per-agent rules for that agent that the child's config lacked.
This revises ADR 0005's "Base ruleset (agent-neutral resolution)" section, which resolved with `agentName` undefined and deferred the semantics to "once principal identity lands."
The rest of ADR 0005 is preserved: recorded-authority-first, escalate `ask`, and provenance-as-data-on-the-ask are unchanged.
The serving node asks its resolver for a decision; it never asks the wire object (Tell-Don't-Ask):
```typescript
// Serving node, per forwarded request ([#597] shape — illustrative, not built here):
const intent = request.accessIntent; // the required field (§2)
const decision = resolver.resolve(
buildResolvedIntentFromWire(intent), // match values used as-is; no PathNormalizer re-derivation
{ agentName: intent.principal.agentName }, // §3 — agent-scoped
);
// allow → auto-approve; deny → auto-deny; ask → escalate through AskEscalator (unchanged).
```
### 4. Version skew — no facts, no judgment, escalate
`ForwardedAccessIntent` is the sole resolution path; the legacy display-only `(surface, value)` resolution branch in `ServingPolicy` is retired ([#597]).
A request that arrives **without** the field floors to `ask` → prompt.
It is never a hard deny (which would break a legitimate in-flight request) and never a silent grant.
Under the principle this is a derivation, not a tolerance hack: missing facts make recorded judgment impossible, so the ask goes straight to live authority.
The realistic skew window is narrow — a long-running parent process holding older code while a freshly spawned child loads newer code across a `pnpm install` version bump, or an old request file read by a newer parent.
A required field with an `ask` floor keeps the ADR 0005 fail-safe direction while shedding the permanent dual-path complexity a tolerant reader would carry.
### Composition — the decision in the authorization walk
Authorization is a walk up a session tree.
At each node an ordered sequence of judges examines the same fixed facts; the only inter-node operation is the courier move, which carries facts and never judgment.
```text
decide(node, facts):
verdict = node.rules.resolve(facts, principal) # recorded authority (deterministic judgment)
if allow or deny → return verdict
for link in node.chain: # non-terminal judges (Track B — ADR 0007)
v = link.review(facts) # allow* / deny / defer (* capped by the checkpoint)
if v ≠ defer → return v
return node.terminal.authorize(facts) # terminal slot:
LocalUserAuthorizer → human decides # terminal judgment
ParentAuthorizer → decide(parent, facts) # courier — recurse up the tree
DenyingAuthorizer → deny # fail-safe
```
- `ParentAuthorizer` occupies the terminal slot for its own node but is a **courier**, not a judge: it carries the facts up and returns the parent node's verdict, exercising no judgment of its own.
This is why serving must re-run recorded authority (the ADR 0005 contract) rather than treat arrival at the parent as "needs a human now."
- Track A (this record) and Track B (`docs/decisions/0007-model-judge-authorizer-chain-adr.md`) are orthogonal axes of one structure: **fidelity of facts between nodes** versus **plurality of judges within a node**.
- Once both tracks land, a serving node's chain links (for example, the model judge) review forwarded asks against the **child-fixed fact set** — honest evidence, not a parent-side re-derivation.
This section is *descriptive* of decided architecture (ADR 0005's serving flow, ADR 0007's chain) and decides nothing new about either; it exists so the two tracks are legible as halves of one picture.
### Explicitly deferred edges
The unified model is known-incomplete at two edges, recorded here rather than left silent:
- **Single-surface fact set** ([#565] item 3).
A child decision can layer multiple surfaces — an `external_directory` check over a `path` — but `ForwardedAccessIntent` carries one surface and one match set.
A multi-surface child decision still floors to `ask` at the parent (the safe direction).
The fact schema may grow additional surfaces later without changing the principle.
- **Multi-hop principal identity.**
Whether a grandchild-through-child forward carries the originator's identity or an accumulated chain is undecided; forwarding today is effectively one hop to the UI-bearing root.
Facts-at-origin answers the path question regardless; identity accumulation is deferred until multi-hop forwarding exists.
## Rejected alternatives
- **Re-derive the path at the parent** (ship the raw typed path plus the requester cwd; the parent rebuilds an `AccessPath` with its own normalizer scoped to the child cwd).
Rejected: it re-introduces the node-of-interpretation flaw the spine exists to remove, and the child-fixed alias set already carries a cwd-relative form, so the parent gains nothing by rebuilding.
- **Agent-neutral serving** (keep resolving with `agentName` undefined; `requesterAgentName` stays display-only).
Rejected: it leaves [#565] item 2 permanently undecided and cannot honor a parent's per-agent rule for the requesting agent.
Agent-scoped serving is a strict superset — identical configs still prompt — so it dominates the neutral choice.
- **Hard-reject a request missing the intent field.**
Rejected: a hard deny breaks a legitimate in-flight request during the rare upgrade window, which is harsher than the established `ask`-floor fail-safe and grants nothing in return.
- **Tolerant dual-path** (keep the legacy `(surface, value)` resolution branch alongside the new intent path indefinitely).
Rejected: it carries permanent dual-path complexity for a skew window that is narrow by construction; a required field with an `ask` floor is the same safety with one code path.
## Consequences
- The forwarded wire gains a required `ForwardedAccessIntent` field carrying child-fixed facts; serving resolves against it at gate parity ([#596], [#597]).
- A parent `allow`/`deny` governs a child's path ask against the **child-fixed** alias set: a `/tmp/*` allow at the parent matches exactly what the child's own gate would have matched, and a relative rule stays relevant across worktrees.
- A relative parent `allow` auto-grants a same-relative path from an unrelated child cwd — consistent with how relative rules already behave locally, and an accepted consequence of least-privilege absolute rules being available when concreteness is wanted.
- `requesterAgentName` becomes decision-participating; a serving node applies its per-agent overrides for the requesting agent.
- [#565] items 23 are structurally dissolved once [#597] lands.
[#565] stays open through Phase 12 by roadmap decision and closes at phase end with a note recording that item 1 (forwarded-prompt fidelity against a real external notification consumer) stays best-effort, since no consumer exists to verify against.
- No code, config, schema, or default changes in this documentation step.
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#486]: https://github.com/gotgenes/pi-packages/issues/486
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#565]: https://github.com/gotgenes/pi-packages/issues/565
[#595]: https://github.com/gotgenes/pi-packages/issues/595
[#596]: https://github.com/gotgenes/pi-packages/issues/596
[#597]: https://github.com/gotgenes/pi-packages/issues/597
@@ -0,0 +1,163 @@
---
status: accepted
date: 2026-07-24
---
# 0009 — The bash path projection is a completeness contract, not a best-effort heuristic
## Status
Accepted.
This decision states the contract the bash path projection upholds, and settles how a "the gate missed my path" report is triaged.
It is the framing for [#645], which closes two gaps the contract names as in-scope; it composes with `docs/decisions/0003-git-bash-posix-path-semantics.md` (win32 token shapes) and `docs/decisions/0007-model-judge-authorizer-chain-adr.md` (the judge that absorbs false positives).
## Context
The bash path gates decide which argument tokens of a shell command are filesystem operands, so the `path` and `external_directory` surfaces can rule on them.
This projection has been patched five times in response to individual bypass reports:
- [#494] / [#509] — bare filenames (`cat id_rsa`) bypass the `path` surface; fixed with rule-driven promotion, matching the **raw token** against specific non-`*` `path` rules.
- [#520] — win32 backslash-relative tokens (`dir\file`) are not recognized as path-shaped.
- [#533] — Git Bash/MSYS POSIX-absolute tokens resolve wrongly on win32.
- [#583] — a bare `/` (filesystem root) is rejected before the path surfaces.
- [#645] — a bare in-project **symlink** whose *target* is denied, and a path embedded in an option (`--file=/tmp/patterns`).
Each fix was correct in isolation, and each was followed by another report of the same shape.
That recurrence is the signal worth acting on: the reports are not independent bugs but repeated encounters with an unstated boundary.
The structural cause is that token classification was **binary** — a token is a path candidate or it is not — while the domain is **three-valued**:
- **Definitely a path** — the shape says so (leading `/`, `~/`, `..`, a separator, a drive letter).
- **Definitely not a path** — the shape rules it out (a flag, a URL, an env assignment, an `@scope` package, a regex).
- **Unknown** — a bare word (`status`, `id_rsa`, `outside-link`), which may name a file or may be a subcommand, branch, or search pattern.
Binary classification collapses *unknown* into *not a path*, and that collapse is silent and fail-open: an unknown token is dropped before any gate sees it, so a permissive bash rule (`cat *`) decides the call and the `path`/`external_directory` policy never runs.
[#509] addressed one slice of *unknown* by consulting the ruleset, which coupled the classifier to policy and still missed any token whose **resolved** identity — not its spelling — is what a rule names.
A symlink is exactly that case: `outside-link` matches no rule by name, and its target is never computed because promotion is decided before resolution.
## Decision
### The principle — candidacy from the filesystem, decision from policy
The projection resolves *unknown* with the filesystem rather than with the ruleset:
> A bare token is a path candidate **iff it names an existing filesystem entry**.
> A promoted candidate is then gated by explicit `path`/`external_directory` rules, or by resolving outside the working tree — never by the universal fallback.
Candidacy and decision are separate concerns with separate sources.
Candidacy asks "is this a file?"
and the filesystem answers authoritatively.
Decision asks "may it be touched?"
and the composed ruleset answers.
The classifier therefore needs no knowledge of policy, and policy needs no knowledge of token spelling.
The universal-fallback exclusion is what keeps this from becoming a prompt firehose, and it needs no new mechanism: `describeBashPathGate` already treats a check whose `matchedPattern` is `undefined` — only the synthesized universal default matched — as unrestricted ([#58]), and `permission-manager.ts` sets `matchedPattern` only for `config`/`session`-layer rules.
A promoted token that matches no explicit rule is therefore unrestricted for free.
### What the projection guarantees
A path reaches the `path` and `external_directory` surfaces when it appears as:
- A **shape-classified token** — absolute (`/x`), home-relative (`~/x`), parent-traversal (`../x`), separator-bearing (`a/b`), a Windows drive-letter path (`C:/x`, `D:\x`), or — under the win32 flavor — a backslash-relative token (`dir\file`, [#520]).
- A **redirect target** (`> out.txt`, `2>/tmp/log`).
- A **value embedded in a long option** (`--file=/tmp/patterns`), split at collection time and classified by the ordinary shape rules ([#645]).
- A **bare token naming an existing filesystem entry** — the existence probe ([#645]).
Its canonical (symlink-resolved) form is what policy matches, so a symlink is gated by rules naming its target ([#493]).
- A **plain `$HOME` / `${HOME}` / `$PWD` / `${PWD}` reference**, resolved at token collection before classification ([#694]).
`$HOME/x` is therefore gated exactly as `~/x` and as the literal absolute spelling, independent of whether the target exists; `$PWD/x` is gated exactly as `./x`.
- Any of the above resolved against the **effective working directory** after literal current-shell `cd` folding; a non-literal `cd` renders the base unknown and keeps tokens literal-only ([#393]).
These guarantees are **positional-invariant**: they hold for a command's own operands wherever that command appears.
A command nested in a substitution is itself gated ([#306]), so its operands are projected whether the substitution sits in argument position (`diff <(cat /etc/shadow)`), in a redirect destination (`echo hi > $(cat /etc/shadow)`), or in an interpolating heredoc body ([#741]).
This is a guarantee, not a residual — see the note under "Computed paths" below for the boundary it is easily confused with.
Opacity is handled separately and conservatively: a wrapper command that hides its payload (`bash -c`, `eval`, `sudo`, `xargs`, …) is floored from `allow` to `ask` rather than projected.
### What the projection deliberately omits
These are **accepted residuals**, not open bugs:
- **Nonexistent bare write targets** (`touch newfile`, `mv a newfile`) — the probe cannot see a file that does not exist yet.
Redirect targets, the common creation path, are collected separately and unaffected.
- **Glued short-option values** (`-f/tmp/x`) — distinguishing a glued value from a cluster of boolean flags (`-rf`) requires per-command option knowledge.
- **Computed paths** other than the plain `HOME`/`PWD` references above — any other `$VAR`, a command substitution (`$(cmd)`), an operator-bearing expansion (`${HOME:-/tmp}`, `${#HOME}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
The residual here is the **value the substitution evaluates to** — the filename `> $(cmd)` ultimately writes to is not knowable without running `cmd`.
It is **not** the nested command's own literal operands, which the positional-invariance guarantee above covers.
Reading this bullet as sanctioning the latter is what let [#741] persist.
Where a computed value affects the working directory, the unknown-base machinery already degrades conservatively.
Two ways to close the assignment case were considered and declined during [#694], measured over 2767 deduplicated real bash commands from the permission review log: same-program literal-assignment dataflow, which reaches **45 (1.6%)** of commands but adds stateful dataflow to the AST walk; and flooring any command carrying an unresolved-expansion path operand to `ask`, which would newly prompt on **194 (7.0%)** — the prompt-firehose outcome this ADR rejects for the bare-token case below.
- **Per-command argument semantics** — which positional argument of `grep`/`git`/`kubectl` is a file.
`PATTERN_FIRST_COMMANDS` encodes a deliberately small exception for pattern-first commands; generalizing it means shipping and maintaining an option table per tool.
### The layering principle — surface deterministically, discriminate with judgment
The deterministic layer biases toward **surfacing**: when a token could be a real operand, it becomes an `ask` rather than a silent allow.
It does not try to decide whether an ask is *warranted* in context — that is the model-judge Authorizer chain's job ([#620], ADR 0007), which reviews a surfaced ask with the full command in view and can dismiss `git grep id_rsa` as a search pattern.
The asymmetry justifying this split: **over-suppression is unrecoverable, over-surfacing is recoverable.**
A path silently dropped is a bypass with no later opportunity to catch it; a path surfaced unnecessarily is a prompt a human or a judge link resolves.
So the deterministic layer never trades a missed operand for a quieter prompt, and per-command cleverness belongs above it, not inside it.
### Determinism and the filesystem
Filesystem state is part of the decision input: existence (this ADR) and symlink targets ([#493]).
The invariant is therefore stated over that input — *same policy + same filesystem state + same command → same decision* — not over the command alone.
This is not a new concession.
Canonicalization made resolution filesystem-dependent when it shipped, and it is the only sound treatment: a symlink's meaning simply is not a property of its name.
Ambient, non-filesystem host state (environment variables, which shell binary was resolved, `cygpath` output) remains excluded, per ADR 0003 — with two named, closed exceptions ([#694]):
- **`HOME`**, resolved via `os.homedir()`.
This is not a widening: `expandHomePath` already resolved `~` and `$HOME` in config rule patterns, `piInfrastructureReadPaths`, and path policy literals, so the exception existed and only the bash projection disagreed with it.
- **`PWD`**, resolved to the projection's own effective base.
It reads no environment at all, so it is strictly more deterministic than `HOME`.
The set is closed: adding a third name is an ADR amendment, not an implementation detail.
Every other variable keeps its literal text, so ADR 0003's rejection of `cygpath` shell-outs and MSYS environment detection stands untouched.
Empirically the probe is highly selective: over 2358 deduplicated real bash commands from the permission review log, 3535 bare tokens survived the rejection prelude and **118 (3.3%)** named an existing entry.
Cost is ~0.04 ms p95 per command, ~19% of the already-paid tree-sitter parse.
## Rejected alternatives
- **Promote every bare token to the `path` surface (literal read-tool parity).**
Rejected: the universal fallback defaults to `ask`, so every bare argument of every command (`git status`, `npm run build`) would prompt.
Parity with the read tool is the wrong target — a read-tool input is known to be a path, and a bash argument is not.
- **Keep rule-driven promotion and widen it** (match `*` patterns, or match canonical forms too).
Rejected: it couples the classifier to the ruleset, makes candidacy depend on policy shape, and — matching spelling rather than identity — still cannot see that `outside-link` is `.some.secret`.
- **Floor to `ask` whenever a bare token cannot be proven safe.**
Rejected: this defeats any `bash` allow rule under a restrictive path policy, which is the configuration users reach for precisely to reduce prompting.
- **Per-command argument tables.**
Rejected as a deterministic-layer mechanism: unbounded maintenance surface, and it duplicates in brittle static data what the judge link ([#620]) does with the command in context.
## Consequences
- A "the bash gate missed my path" report is now triaged against this contract: it is either **inside** it (a bug — the projection failed a guarantee) or **outside** it (an accepted residual, or a judge-layer concern).
This is the durable outcome; the recurrence in Context was a symptom of having no such test.
[#694] is the first report triaged this way, and it split: its `$HOME`/`${HOME}` half was **inside** (the package resolved `$HOME` for patterns and path literals but not for bash tokens, so a guarantee was inconsistently met) and was fixed; its assignment-dataflow half was **outside** and was declined with the numbers above.
A single report landing on both sides is the expected outcome of having the line drawn.
- [#741] is the second report triaged this way, and it landed **inside**: a substitution's operands were projected in argument position but not when the substitution sat in a redirect destination or an interpolating heredoc body, so a guarantee was met inconsistently across positions — the same shape as [#694]'s `$HOME` half.
The fix names the hosting concept once (`EXECUTION_HOST_TYPES` in `access-intent/bash/nested-execution.ts`), shared by the command surface and the path surface so the two cannot drift on what counts as a nested execution.
Measured over 2950 deduplicated real bash commands, **0** hosted a substitution in a redirect target and **0** carried an unquoted heredoc with one, so closing it produced no new prompting on realistic traffic.
- The [#509] promotion thread is deleted: `PathRuleTokenMatcher`, `PermissionManager.getPromotablePathTokenMatcher`, and the five-layer parameter thread from manager to resolver.
The classifier is once again pure and policy-free.
- `PathNormalizer` gains `entryExists`, keeping the filesystem edge in the same object that owns canonicalization; the classifiers stay pure shape functions.
- Bare tokens naming existing files become gateable, so a config using `path`/`external_directory` denies now sees operands it previously missed — a breaking behavior change on upgrade ([#645]), remediated with `path`/`external_directory` allow patterns.
- Expansion resolution lives at token collection (`resolveNodeText``shell-variable-expansion.ts`), never in the classifiers.
Teaching `classifyTokenAsPathCandidate` a `$HOME` prefix instead would have put the home-directory vocabulary in a second place and reproduced the drift that caused [#694]; resolving upstream keeps the classifiers pure shape functions that need no per-variable knowledge.
- The probe adds one `lstat` per prelude-surviving bare token with a known base.
If a future workload makes that cost material, the fallback is to gate the probe on "any explicit `path`/`external_directory` restriction exists in config" — a pipeline-level consult that still keeps the classifier policy-free.
[#58]: https://github.com/gotgenes/pi-packages/issues/58
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#493]: https://github.com/gotgenes/pi-packages/issues/493
[#494]: https://github.com/gotgenes/pi-packages/issues/494
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#520]: https://github.com/gotgenes/pi-packages/issues/520
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#583]: https://github.com/gotgenes/pi-packages/issues/583
[#620]: https://github.com/gotgenes/pi-packages/issues/620
[#645]: https://github.com/gotgenes/pi-packages/issues/645
[#694]: https://github.com/gotgenes/pi-packages/issues/694
[#306]: https://github.com/gotgenes/pi-packages/issues/306
[#741]: https://github.com/gotgenes/pi-packages/issues/741
@@ -0,0 +1,121 @@
---
status: accepted
date: 2026-07-25
---
# 0010 — Permission logs are mode-restricted and key-name redacted, not secret-detected
## Status
Accepted.
This decision states what the permission logs protect against and what they do not, so a report of the shape "the log contains a secret" can be triaged against a written contract rather than re-argued.
## Context
The permission review log is enabled by default and records every gate decision.
Two of its fields carry payload rather than metadata: `command`, the complete bash command string, and `toolInputPreview`, a serialized JSON preview of a non-bash tool's input bounded at 1000 characters.
The debug stream carries the same payload again when `debugLog` is on.
[#647], a third-party report, observed that these values are persisted without redaction and that the files are appended without an explicit mode, so their permissions follow the process umask.
Both observations were accurate.
Measured on the reporter-equivalent installation, the review log was 6.7 MB across 8380 lines with mode 0644 — world-readable — and the logs directory 0755.
The report proposed two remedies: redact common secret forms before persistence, and create the files owner-only.
These address different adversaries, and conflating them is what makes the issue recur.
| Adversary | Closed by owner-only modes | Closed by redaction |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Another local user on a shared host | Yes, completely | Redundant |
| A backup or cloud-sync agent copying `~/.pi` | No — it runs as the user | Yes |
| The user pasting a log excerpt into an issue | No | Partially |
| The agent reading its own log | Already closed — the logs directory is outside the session cwd, so the `external_directory` gate prompts, and `isPiInfrastructureRead` does not auto-allow it | Only relevant where the operator has allowed `~/.pi/**` reads |
## Decision
### Owner-only modes, unconditionally
Both JSONL logs are created `0600` and the logs directory `0700`; permission-forwarding request and response files and their directories likewise.
Because a `mode` option applies only when the call creates the path, each log is additionally `chmod`-ed once per session on first write — an installation predating this change would otherwise keep its world-readable log indefinitely.
`mkdirSync`'s `recursive` mode applies to every directory it creates, so a fresh install also gets an owner-only extension config directory.
Directories that already exist are never modified, so an operator's chosen layout above the logs directory is untouched.
### Key-name redaction, not value-shape detection
A value bound to a key named `authorization`, `token`, `secret`, `password`, `passwd`, `credential`, `cookie`, `api_key`, or `private_key` (case-insensitive, separator-tolerant) is masked with `[redacted]` before serialization.
The technique is deliberately **structural rather than predictive**: a value is masked because of the name it is bound to, never because of what it looks like.
This is applied at two points, and the second is not redundant:
1. `writeLine` in `src/logging.ts` — the single point where either stream reaches disk, covering any call site that logs a nested object.
2. `serializeRedactedToolInputPreview`, reached from `formatGenericToolInputForLog` — because `getToolInputPreviewForLog` flattens the tool input to a string *before* the details record reaches the writer, so by point 1 its keys no longer exist to match.
Point 2 is what closes the reporter's literal repro.
### The prompt is never redacted
`formatToolInputForPrompt` and the forwarding request/response files stay unredacted.
The user must see the real input to make a permission decision, and the forwarding files exist so the parent can render that prompt.
Masking either would blind the approver — a permission regression dressed as a security fix.
## Alternatives considered
### Value-shape secret detection — declined
A provider-prefix list (`sk-`, `ghp_`, `AKIA`, `xox`, `Bearer`, PEM markers) or an entropy heuristic.
Declined on measured evidence.
Probing the live 6.7 MB review log for exactly those shapes:
| Probe | Hits | What they were |
| ------------------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sk-` | 403 | 356 the tail of `task-approval`, 275 of `task-user`, 146 of `task-no-ui` — all substrings of `task-*`. The 4 genuine `sk-ant-oat…` shapes sat inside a grep pattern the agent had typed. |
| `xox` | 2 | Inside the tool-use id `toolu_01VdGtvuHfmxox86kCCkY3`. |
| `API_KEY` | 8 | The literal env-var **name** `ANTHROPIC_API_KEY`; no value. |
| `Bearer `, `ghp_`, `github_pat_`, `AKIA`, `AIza`, `password` | 0 | — |
Anchoring the patterns would fix those particular false positives, but the corpus contained **zero true positives**, so the list would be pure maintenance burden.
More decisively, its failure boundary is unstatable: a redactor that silently misses a key is worse than a documented warning, because it invites treating the log as safe to share.
This is the same reasoning already recorded for [#599] and `docs/decisions/0007-model-judge-authorizer-chain-adr.md`, where a hard-coded secret denylist was declined because the codebase has no formal secrets model.
Secret *detection* is a product category (gitleaks, trufflehog, detect-secrets) with hundreds of continuously-maintained rules; the logging ecosystem's own answer — pino's `redact`, Winston's formats, Serilog's destructuring policies — is uniformly declarative key-path masking, not detection.
### Grammar-anchored bash redaction — declined for now, the option a future report reopens
The package already parses every bash command into a tree-sitter AST and already walks `variable_assignment` nodes to strip env prefixes ([#481]) and embedded option values ([#645]).
Masking the value side of an assignment whose name is sensitive, and the argument following `--token`/`--password`, would extend coverage to `FOO_TOKEN=abc deploy` with near-zero false positives, because it operates on parse nodes rather than on a guess about what a string looks like.
Not taken here: it is materially more work than the key-name pass, and no reported case yet demands it.
It is recorded as the concrete next step should a report show a secret reaching the log through a command string.
### Making raw payload logging opt-in — declined
Flipping `permissionReviewLog` to `false`, or gating `command`/`toolInputPreview` behind a new `logToolInput` flag.
Declined as a breaking change that trades away the package's stated priority that block/ask/allow decisions stay reviewable by default.
`matchedPattern` without `command` makes "what exactly did the agent run at 14:32" unanswerable, which is the main reason to read this log.
### A downstream redactor registry — declined
A `PermissionsService.registerLogRedactor(name, redact)` mirroring `ToolInputFormatterRegistry` / `ToolAccessExtractorRegistry` / `AuthorizerRegistry`.
Structurally cheap and low-novelty, but it would ship with zero consumers, which is precisely the maintenance trap the package's own guidance warns against.
Revisit if a concrete downstream asks.
## Consequences
- The stated boundary, which every user-facing mention repeats verbatim: **a value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.**
- A key legitimately named `token` carrying a non-secret now reads `[redacted]` in the log.
Accepted: the key set is narrow, and every structured field the gate logs (`toolName`, `action`, `reason`, `matchedPattern`, `origin`, `resolution`) falls outside it.
- The change is POSIX-effective only.
On Windows `chmod` toggles only the read-only bit and the `mode` options are ignored, so the files there are governed by NTFS ACL inheritance.
The `chmod` failure is swallowed rather than warned about, because a warning every session on Windows would be noise.
- A custom formatter registered through `ToolInputFormatterRegistry` returns an opaque string for a path-bearing tool, which this change cannot mask.
A registrant emitting credentials into a log preview is responsible for its own output.
- Because a hardening failure never throws, no new failure mode reaches the fail-closed tool-call boundary.
[#481]: https://github.com/gotgenes/pi-packages/issues/481
[#599]: https://github.com/gotgenes/pi-packages/issues/599
[#645]: https://github.com/gotgenes/pi-packages/issues/645
[#647]: https://github.com/gotgenes/pi-packages/issues/647
@@ -0,0 +1,284 @@
---
status: accepted
date: 2026-08-14
---
# 0011 — The prompt-presentation contract: a complete payload and a bounded render
## Status
Accepted.
This decision states what a permission ask prompt must show, what a renderer may elide, and what bounds its size, so a proposal to change the prompt is judged against a written contract rather than re-argued per pull request.
It composes with `docs/decisions/0007-model-judge-authorizer-chain-adr.md` (who decides) and `docs/decisions/0010-permission-log-secret-exposure.md` (what the logs persist); it decides presentation only, never policy.
## Context
Six open items change how a permission prompt presents itself, and they pull in opposite directions.
| Item | Wants |
| ------ | ------------------------------------------------------------------------- |
| [#710] | the prompt bounded in **height** |
| [#656] | the assembled message hard-truncated to 200 characters (bounded in width) |
| [#716] | the message **expanded** into aligned `key : value` lines + pretty JSON |
| [#713] | the inner command of unstrippable wrappers **added** to the prompt |
| [#648] | edit diffs **added** before approval |
| [#654] | contextual natural-language explanations **added** |
Three ask the prompt to show more and two ask it to show less.
The two pull requests edit the same function in opposite directions, so whichever merged first would have silently set the premise the other was reviewed against.
The stakes are not cosmetic.
This package's rule is that the prompt's tool input is never redacted, because the user must see the real input to decide.
Eliding for size therefore trades directly against the decision quality the gate exists to protect.
### What the code did before this decision
Five sites assembled prompt text independently — `formatAskPrompt` (bash / MCP / generic-tool branches), the two skill prompts, the two external-directory prompts, the per-tool input previews, and the parent-side forwarded prefix — each producing a flat `string`.
That string became `PromptPermissionDetails.message` and travelled unchanged to every consumer: the inline TUI dialog, the `select`/`input` fallback, the review log, and the `permissions:ui_prompt` broadcast.
Three properties of that arrangement are the direct causes of the six items above:
- **The bash branch had no cap at all.**
It interpolated the raw command and the full command verbatim, as did the bash external-directory prompt.
The two configurable caps, `toolInputPreviewMaxLength` (200) and `toolTextSummaryMaxLength` (80), bounded only the non-bash JSON and search-summary previews — which is why they did not bound the prompt, and why [#656] concluded the assembled message was unbounded.
- **Nothing bounded height.**
`fitToWidth` wraps each line explicitly "so no content is lost", and the resulting row count is unbounded by construction.
- **A forwarded ask was assembled twice, under two configs.**
The child assembled its message under *its* limits, wrote it into the request file, and the parent prefixed three lines and rendered it.
The parent's own limits never applied to the child's text, so consistency across local and forwarded asks was not merely unstated — it was structurally unattainable while the payload was a pre-rendered sentence.
### What the host already does
Verified against the sibling Pi checkout at `../pi` (`9d2ec7ffa`, 2026-08-13); every API cited is present in the pinned `@earendil-works/pi-coding-agent`.
- The pending tool call's transcript component is created on `message_update`, **before** `beforeToolCall` invokes `emitToolCall`.
So for a local ask, the host has already rendered the pending call above our dialog.
- What it renders differs per tool: `bash` shows `$ <full command>` unbounded and ignores the expansion flag; `write` caps its content preview at 10 lines unless expanded; `read` shows a compact classification unless expanded; `edit` computes and renders a **full diff** before the result exists.
- `ToolRenderContext.expanded` reaches the *call* renderer, not only the result renderer, so Pi's tool-expansion action genuinely expands a pending `write` or `read` — and does nothing for `bash` or `edit`, whose renderers ignore it.
- A forwarded ask has **no** host block at all: the parent's dialog is raised by this package's forwarded-request poll, not by a parent `tool_call`, and each session owns its own message list.
For a forwarded ask the prompt is the sole carrier of evidence, and tool expansion has nothing to expand.
The last point is why [#710] reported the worst case on a subagent ask specifically.
### Prior art
- **Codex** merged a change titled "tui: fix approval dialog for large commands" that emits a proposed-command history cell on an approval request, simplifies the dialog to the reason alone, and truncates decision-history snippets to a single line and 80 graphemes.
Its answer is to separate the evidence surface from the decision surface.
- A Codex user reported the approval dialog showing only the text before `&&`, approving a command whose second half was never displayed.
That is a decidability failure caused by *structural* elision, and it is [#713] in another product.
- **Claude Code** carries both complaints at once: one report asks for multi-line bash arguments to be rendered in full in the approval dialog, and another reports that a subagent's large inline bash payload rendered in full froze the terminal.
Same product, opposite demands — the empirical proof that content rules alone cannot satisfy both, and that a bounded default needs a reachable full view.
A third report treats ~100-character truncation with no way to expand as a defect, because the user must approve destructive calls without seeing them.
- Claude Code's explanation affordance is on-demand (generated only on an explicit keypress), labelled with a risk level, toggleable, and disableable by setting — the shape [#654] asks for, already shipped elsewhere as an opt-in rather than a default.
## Decision
### 1. What an ask prompt is for
An ask prompt routes human attention to a consequential action and supplies enough evidence to decide it.
Elision that removes decision-relevant evidence is a **correctness bug**, not a cosmetic one, and is triaged as such.
### 2. The payload is complete; elision is a rendering concern
A gate emits a **complete** structured payload describing the request.
It never pre-renders a sentence, never truncates, and never decides what a human will see.
Every consumer is a **renderer** over that payload, deciding under its own budget what to show, in what order, and in what format.
> The payload is complete by contract.
> Elision is a property of a render, never of the payload.
This is the rule that resolves the six items: three of them are renderer decisions, and the two pull requests were both editing the wrong layer.
It also dissolves the double-assembly problem, because a forwarded child now ships facts and the serving node renders them under its own budget.
An illustrative shape — the implementing issue owns the exact types:
```typescript
interface PromptPayload {
/** Never elided by any renderer. */
request: {
requester: {
agentName: string | null;
forwarded: boolean;
sessionId: string | null;
};
surface: string;
toolName: string | null;
invokedToolName: string | null;
value: string;
matchedPattern: string | undefined;
executedUnit: string | null;
};
/** Complete; each renderer elides to fit its own budget. */
evidence: ReadonlyArray<{ label: string; text: string }>;
/** Supplied by registered annotators; always marked as model-generated. */
annotations: ReadonlyArray<{ source: string; text: string }>;
}
```
### 3. The invariant core
The payload's `request` group carries the facts that are always visible, in every render, and that no budget may elide.
It is named for what it holds — the permission request's own facts, matching the package's `PermissionRequest` / `ForwardedPermissionRequest` / `permission_request.*` vocabulary — rather than for its contract, which this section states instead:
1. The requesting agent, whether the ask was forwarded from a subagent, and — for a forwarded ask — the requesting session id.
2. The tool name — and the invoked tool name as a distinct fact when a shell alias re-exposes bash, since "gated as `bash`, invoked as `exec_command`" is two facts.
3. The gate surface and the matched rule, including a sentinel such as `<indirection-bash-wrapper>`.
4. The decision-relevant value: the command, path, MCP target, or skill name.
5. For bash, the executable unit that will actually run, **including inside an unstrippable wrapper**.
6. An explicit marker on any part of the prompt that is model-generated.
Point 5 promotes [#713] from an enhancement to a conformance requirement.
The Codex `&&` report is the evidence: a prompt that names a wrapper without naming what it runs has not shown the user the action they are approving.
### 4. Elision rules
A renderer may elide anything outside the invariant core.
An elision is marked — an ellipsis or an equivalent indicator — and states nothing more.
Character and line counts were considered and rejected: they are a number the user cannot act on, and they consume budget that the evidence itself should hold.
What matters is not how much was hidden but that the user can reach it, which is the next rule.
An operator must be able to reach the **complete** information while the decision is pending.
This is a capability requirement, not a mechanism: an in-dialog expansion, an overlay with a maximum height, a scrollable region, or a separate detail view all satisfy it.
The implementing issue chooses the mechanism on ergonomics, subject only to that capability holding for local, forwarded, and skill asks alike.
### 5. Size bounds
A render is bounded by a **height budget in rows**, plus a **per-field width cap**.
Rows are the unit because the reported failure is a viewport takeover.
The width cap exists because a single pathological field — a here-string on one logical line — would otherwise consume the entire row budget through wrapping.
A component is never told the terminal height, so the row budget is a default the operator may configure, not a value read from the host.
`toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` are **subsumed** by the renderer budgets.
They are soft-deprecated: the fields stay optional in the schema and their values are ignored, and a config that sets either receives a deprecation notice through the existing config-issue channel.
They are not removed, because strict validation rejects an unknown field fail-closed and an upgrade must not empty an operator's policy.
### 6. The four renderers
One payload, four renderers, each with its own budget and its own configuration.
| Renderer | Budget | Notes |
| --------------------------------- | -------------------------------- | ---------------------------------------------------- |
| Inline TUI dialog | row budget + per-field width cap | the bound that answers [#710] |
| `select`/`input` fallback | same budget | no assumption of an expansion affordance |
| Review log | its own configured limits | key-name redaction unchanged; exposure does not grow |
| `permissions:ui_prompt` broadcast | `request` only | no `evidence`, no `annotations` |
Requester identity is part of the `request` facts, not evidence, so narrowing the broadcast does not touch it.
The forwarded provenance the broadcast carries today — `requesterAgentName` and `requesterSessionId` — is retained in full: [#292] added it precisely so a forwarded ask's broadcast stays non-degraded, [#610] builds on it to correlate a decision back to the serving session, and `permission-events.ts` guarantees its fields are not removed without a semver-major bump.
What narrows is evidence, never correlation.
The broadcast is the narrowest renderer, and deliberately narrower than what it emits today.
Any loaded extension can observe the bus without the operator having named it, whereas every route to evidence — a registered tool-input formatter, an `Authorizer` link the operator lists in `authorizerChain` — requires that consent.
So the bus receives the request facts and the verdict, and nothing a renderer would have had to elide.
For a bash ask this discloses no less than today, because the command is the request's `value`; for a `write`, an `edit`, or an MCP call it discloses the path and the verdict rather than the body, where today an incidental preview of up to 200 characters rides `message`.
The review log renders the payload under its existing limits rather than persisting it whole.
This is deliberate: `docs/decisions/0010-permission-log-secret-exposure.md` bounds what the logs accumulate, and a complete payload written verbatim on every ask would defeat that bound.
A renderer's budget is where log growth is decided, and it is configurable there.
### 7. The agent-facing renderer
Denial text is a fifth render of the same facts, and the denial path already works this way — it takes a structured `DenialContext` and renders at the edge.
The rule for it is different in one respect:
> The agent renderer **identifies** the call; it does not **reproduce** it.
The agent authored the tool call, so echoing its input back tells it nothing it did not already have.
The new information is the verdict: which surface gated the call, which pattern matched, whether a differently-shaped retry could succeed, and what the human said.
Because the renderer never echoes the input, it needs no separate size bound — the rule bounds it structurally.
This closes a real defect: every denial path previously interpolated the raw command verbatim, so the same oversized payload that took over the viewport in [#710] was echoed into the agent's context in full whenever the user denied it.
The human's constraint is rows; the agent's is tokens; the same unbounded payload violated both.
On what reaches the agent:
- **Forbidden**: annotations.
A model-generated advisory returned to the agent becomes an instruction, and the agent's model would be reading another model's opinion of its own request as if it were policy.
- **Permitted, and affirmed**: the human's typed denial reason.
It already flows, by design — that is what the "No, provide reason" option is for — and it must not be reclassified as leakage later.
- **Not a question**: evidence a renderer elided from the human's view.
The agent already has it, so no filter is warranted and none should be built.
### 8. Extension seams
Two capabilities belong downstream, with this package owning only the seam.
**Annotations** ([#654]).
A named, opt-in, config-ordered annotator registry, mirroring `registerAuthorizer` and `registerToolInputFormatter`, fails safe when a configured name is unregistered.
Four properties make it admissible: this package owns the payload slot, its attribution, and its model-generated marking, so the marker is a property of the slot rather than a discipline a downstream package must remember; the slot is structurally separate from `AuthorizerVerdict`, so an annotator cannot allow, deny, defer, or suppress; it is timeout-bounded with an unchanged-prompt fallback; and it runs at the serving node, where the human is, per `docs/decisions/0007-model-judge-authorizer-chain-adr.md` §7.
**Evidence formatters** ([#648]).
The existing tool-input formatter registry produces **evidence entries** rather than strings, so a downstream package can supply richer evidence — a diff renderer among them — without this package growing a display for every operator's ideal.
The payload carries the edit's facts; the renderer decides how to present them and may suppress what the host already displays.
### 9. Representation and skew
The structured payload replaces `message` on both cross-boundary contracts — the on-disk forwarded request and the `permissions:ui_prompt` broadcast — in the same change, rather than carrying both fields indefinitely.
The blast radius was measured, not assumed.
`pi-permission-model-judge` reads `accessIntent.surface`, `surface`, `path`, and `value`, and never `message`, so it is unaffected.
The exposed surfaces are an unknown third-party extension reading `message` off the broadcast, and an out-of-process version-skewed child whose request carries only `message`.
A forwarded request that carries no payload is rendered from whatever fields it does carry, and a prompt is **never** presented empty.
Fail-closed applies to presentation as it does to policy: if the facts cannot be established, the ask still reaches the human with what is known, rather than resolving without one.
## Consequences
- The prompt's content stops being decided at five assembly sites and starts being decided in one renderer per consumer.
A change to what the user sees becomes a renderer change, reviewable against this contract.
- Consistency across local, forwarded, and skill asks becomes achievable for the first time, because the serving node renders the child's facts under its own budget instead of relaying the child's prose.
- Replacing `message` is a breaking change on two contracts and carries a `feat!:` commit and a migration note naming the payload fields that supersede it.
- The review log's growth becomes an explicit, configured decision rather than a side effect of prompt wording.
- Denial text shrinks substantially, and the agent gets a clearer statement of why a call was refused.
- A contributor can check a proposal in three questions: does it keep the invariant core visible, does it change the payload or the render, and does its render fit the budget.
## Alternatives considered
- **A width cap on the assembled string** ([#656]'s shape).
Rejected: it is blind to structure, so it can cut the decision-relevant value while preserving boilerplate, and it bounds the wrong dimension for a viewport complaint.
Claude Code's ~100-character mobile truncation is the same remedy, and it is filed there as a defect.
- **Expanding the assembled string into aligned lines with pretty-printed JSON** ([#716]'s shape).
Rejected as a formatter change while adopted as a *rendering* direction: aligned, one-fact-per-line output is a good render, but implementing it in the assembler would have made the review log persist unbounded unredacted tool input as a side effect of a readability change.
- **Character and line counts on every elision** ([#710]'s specific request).
Rejected: the counts are unactionable, and reachability of the full text is what the request was really protecting.
- **The prompt as a pure decision surface, with the host transcript carrying the evidence** (Codex's answer).
Rejected: it depends on host rendering this package does not control and which does not exist for forwarded asks, RPC mode, or a log excerpt.
The payload must stand alone.
- **Persisting the complete payload to the review log.**
Rejected: it would make the log a full-text destination at the cost of the growth bound `docs/decisions/0010-permission-log-secret-exposure.md` was written to hold.
- **Broadcasting the complete payload, or the payload minus annotations.**
Rejected: both widen what an unconsented observer sees, and the second converts today's capped incidental exposure into a complete one.
An operator-configurable switch to widen the bus was also rejected as a mechanism with no requested use.
- **Retaining the two preview caps alongside the new budgets.**
Rejected: two layers that both sound like they bound the prompt is exactly the confusion this decision removes.
## Staging
The payload and the renderer seam are built first, and [#710] is fixed by construction rather than patched.
The seam's own decomposition is deferred.
Whether the payload, the dialog renderer, the log and broadcast renderers, and the agent-facing renderer land as one issue or several is a planning decision, and the concrete issues are filed by the next `/plan-improvements` pass when the phase is scoped — the same assignment ADR 0007 made for [#472].
That pass also sequences this work against the other open keystones rather than assuming it runs next.
The table below names what each existing item becomes under the contract, not the order in which the seam is built.
| Item | Becomes |
| ------ | --------------------------------------------------------------------------------------------------------------------- |
| [#710] | fixed by construction when the seam lands: a bounded dialog render over a complete payload |
| [#713] | a conformance requirement of the payload's invariant core, not a separate enhancement |
| [#716] | its rendering intent adopted in the dialog renderer, re-implemented under this contract, with authorship credited |
| [#656] | superseded: bounds live in the renderer, and its crash premise was fixed before the pull request was opened |
| [#648] | the payload carries the edit's facts; the renderer decides, and the formatter seam admits a richer downstream display |
| [#654] | a downstream package plus the annotator seam described in §8 |
[#292]: https://github.com/gotgenes/pi-packages/issues/292
[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#610]: https://github.com/gotgenes/pi-packages/issues/610
[#648]: https://github.com/gotgenes/pi-packages/issues/648
[#654]: https://github.com/gotgenes/pi-packages/issues/654
[#656]: https://github.com/gotgenes/pi-packages/pull/656
[#710]: https://github.com/gotgenes/pi-packages/issues/710
[#713]: https://github.com/gotgenes/pi-packages/issues/713
[#716]: https://github.com/gotgenes/pi-packages/pull/716