mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
1240 lines
140 KiB
Markdown
1240 lines
140 KiB
Markdown
# Architecture
|
||
|
||
This document describes the internal design of the permission system, informed by [OpenCode's permission model](https://opencode.ai/docs/permissions/).
|
||
|
||
## Design principles
|
||
|
||
1. **Unified rule model** - one `Rule` type, one evaluation function, all surfaces.
|
||
2. **Pure evaluation** - permission decisions are pure functions of (surface, pattern, rules).
|
||
IO stays at the edges.
|
||
3. **Session approvals are just more rules** - no separate matching engine, no separate pre-check.
|
||
4. **MCP stays special** - multi-name target derivation is pre-processing, not a special evaluation path.
|
||
5. **Defaults are rules** - the universal default (`permission["*"]`) is synthesized as a low-priority rule in the array.
|
||
No side-channel fallbacks.
|
||
6. **Flat config format** - the flat `permission: { ... }` object where each key is a surface.
|
||
The config IS the ruleset in human-friendly form.
|
||
7. **Preserve the two-phase model** - tool filtering (before_agent_start) and invocation gating (tool_call) remain separate.
|
||
8. **Ask = cache miss** - "ask" is the absence of a matching rule.
|
||
The human is the oracle.
|
||
Their decision is a rule.
|
||
Persistence determines lifetime (once / session / config).
|
||
9. **Single-agent core, multi-agent by extension** - Pi is single-agent by deliberate design; the notion of multiple named agents is introduced entirely by external extensions (pi-subagents, pi-agent-router, some MasuRii packages), not by Pi itself.
|
||
Per-agent `permission:` frontmatter is therefore an extension bridge layered on this single-agent core, not a core responsibility.
|
||
The package learns the active agent from a generic `<active_agent>` signal (a system-prompt tag or an `active_agent` session entry), never from a hard dependency on any one multi-agent extension, so the bridge works with any tool that emits the signal.
|
||
|
||
## Core data model
|
||
|
||
### Rule
|
||
|
||
```typescript
|
||
/**
|
||
* Provenance of a rule - which source contributed it.
|
||
*
|
||
* Config scopes: "global", "project", "agent", "project-agent".
|
||
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
||
* "baseline" (conditional MCP metadata auto-allow).
|
||
* Runtime: "session" (session approvals).
|
||
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
|
||
* "fail-closed" (composition-stage allow→ask floor when an
|
||
* invalid non-global config scope is detected).
|
||
*/
|
||
type RuleOrigin =
|
||
| "global"
|
||
| "project"
|
||
| "agent"
|
||
| "project-agent"
|
||
| "builtin"
|
||
| "baseline"
|
||
| "session"
|
||
| "yolo"
|
||
| "fail-closed";
|
||
|
||
interface Rule {
|
||
/** The permission surface: "bash", "edit", "mcp", "skill", "external_directory", "path", etc. */
|
||
surface: string;
|
||
/** The match pattern: a command glob, tool name, file path, skill name, or "*". */
|
||
pattern: string;
|
||
/** The decision. */
|
||
action: PermissionState;
|
||
/** Custom denial reason for deny rules (optional). */
|
||
reason?: string;
|
||
/**
|
||
* Origin layer - used to derive PermissionCheckResult.source after evaluation.
|
||
* Not used by evaluate(); purely informational metadata.
|
||
*/
|
||
layer?: "default" | "baseline" | "config" | "session";
|
||
/** Which source contributed this rule. */
|
||
origin: RuleOrigin;
|
||
}
|
||
```
|
||
|
||
Every config entry, default policy, session approval, and agent override normalizes into `Rule[]`.
|
||
|
||
### Ruleset
|
||
|
||
```typescript
|
||
type Ruleset = Rule[];
|
||
```
|
||
|
||
Merge precedence is array ordering.
|
||
The synthesized universal default goes first (lowest priority), then MCP baseline auto-allow rules, then config rules (global → project → agent → project-agent), and finally session rules (highest priority).
|
||
Last-match-wins: `evaluate()` scans from the end.
|
||
|
||
### Evaluate
|
||
|
||
```typescript
|
||
function evaluate(
|
||
surface: string,
|
||
value: string,
|
||
rules: Ruleset,
|
||
platform: NodeJS.Platform,
|
||
): Rule {
|
||
for (let i = rules.length - 1; i >= 0; i--) {
|
||
const rule = rules[i];
|
||
// On win32 a path-surface match folds case + separators; `platform` is
|
||
// injected from `PermissionManager` (read once at the composition root,
|
||
// #510), never `process.platform` ambiently.
|
||
if (ruleMatches(rule, surface, value, platform)) {
|
||
return rule;
|
||
}
|
||
}
|
||
// Unreachable when defaults are synthesized - the catch-all always matches.
|
||
return { surface, pattern: value, action: "ask" };
|
||
}
|
||
```
|
||
|
||
The entire decision engine.
|
||
When defaults are synthesized into the array, the catch-all `{ surface: "*", pattern: "*", action: "ask" }` always matches - the fallback return is defensive only.
|
||
|
||
## Composed ruleset
|
||
|
||
All rule sources are concatenated into a single flat array.
|
||
Index position determines priority (higher index wins):
|
||
|
||
```text
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Composed Ruleset (Rule[]) │
|
||
│ │
|
||
│ Index 0: Synthesized universal default (layer: "default") │
|
||
│ { surface: "*", pattern: "*", action: permission["*"] } │
|
||
│ │
|
||
│ Index 1..B: MCP baseline auto-allow (layer: "baseline") │
|
||
│ (only when any config rule has surface:"mcp" action:"allow") │
|
||
│ { surface: "mcp", pattern: "mcp_status", action: "allow" } │
|
||
│ { surface: "mcp", pattern: "mcp_list", action: "allow" } │
|
||
│ { surface: "mcp", pattern: "mcp_search", action: "allow" } │
|
||
│ { surface: "mcp", pattern: "mcp_describe", action: "allow" } │
|
||
│ { surface: "mcp", pattern: "mcp_connect", action: "allow" } │
|
||
│ │
|
||
│ Index B+1..C: Config rules (global → project → agent, │
|
||
│ layer: "config", origin: "global"|"project" │
|
||
│ |"agent"|"project-agent") │
|
||
│ { surface: "bash", pattern: "*", action: "allow", │
|
||
│ origin: "global" } │
|
||
│ { surface: "bash", pattern: "git *", action: "allow", │
|
||
│ origin: "global" } │
|
||
│ { surface: "bash", pattern: "rm *", action: "deny", │
|
||
│ origin: "project" } │
|
||
│ { surface: "read", pattern: "*", action: "allow", │
|
||
│ origin: "global" } │
|
||
│ { surface: "mcp", pattern: "exa:*", action: "allow", │
|
||
│ origin: "agent" } │
|
||
│ │
|
||
│ Index C+1..end: Session rules (layer: "session", highest) │
|
||
│ { surface: "external_directory", pattern: "/other/*", │
|
||
│ action: "allow" } │
|
||
│ │
|
||
│ ◄── evaluate() scans from end, first match wins ──► │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
`synthesizeDefaults()` produces a single universal catch-all from `permission["*"]`.
|
||
Per-surface catch-alls (e.g. `bash: { "*": "allow" }`) are expressed as regular config rules via `normalizeFlatConfig()` - no separate override layer is needed.
|
||
|
||
`synthesizeBaseline()` conditionally emits MCP metadata auto-allow rules.
|
||
|
||
`composeRuleset()` concatenates: defaults + baseline + config rules.
|
||
Session rules are concatenated after config rules so `evaluate()` handles them via last-match-wins - no separate per-branch pre-check.
|
||
|
||
### Default synthesis
|
||
|
||
```typescript
|
||
// Single universal catch-all from permission["*"].
|
||
function synthesizeDefaults(universalDefault: PermissionState): Ruleset {
|
||
return [
|
||
{ surface: "*", pattern: "*", action: universalDefault, layer: "default" },
|
||
];
|
||
}
|
||
|
||
// MCP metadata auto-allow - only synthesized when any config rule has
|
||
// surface: "mcp" && action: "allow".
|
||
function synthesizeBaseline(configRules: Ruleset): Ruleset { ... }
|
||
|
||
// Concat in priority order: defaults, baseline, config.
|
||
function composeRuleset(defaults, baseline, config): Ruleset {
|
||
return [...defaults, ...baseline, ...config];
|
||
}
|
||
```
|
||
|
||
## Architecture overview
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph Load["Config loading (IO boundary - PolicyLoader)"]
|
||
GF["Global config file"]
|
||
PF["Project config file"]
|
||
AF["Agent frontmatter"]
|
||
GF --> PL["PolicyLoader"]
|
||
PF --> PL
|
||
AF --> PL
|
||
PL --> Norm["normalizeFlatConfig()"]
|
||
end
|
||
|
||
subgraph Defaults["Default synthesis"]
|
||
DP["permission[*]"] --> Synth["synthesizeDefaults()"]
|
||
Synth --> DR["Default Rule (lowest priority)"]
|
||
Norm --> BL["synthesizeBaseline()"]
|
||
BL --> BR["Baseline Rules (conditional)"]
|
||
end
|
||
|
||
Norm --> CR["Config Rules (layer: config)"]
|
||
SA["Session Rules<br/>(layer: session, runtime)"]
|
||
|
||
subgraph Compose["Rule composition"]
|
||
DR --> Concat["composeRuleset(...) + session"]
|
||
BR --> Concat
|
||
CR --> Concat
|
||
SA --> Concat
|
||
end
|
||
|
||
subgraph Eval["Pure evaluation (no IO)"]
|
||
Concat --> E["evaluate(surface, value, composedRules)"]
|
||
E --> Decision["Rule { surface, pattern, action }"]
|
||
end
|
||
|
||
subgraph PreProcess["Surface-specific input normalization"]
|
||
MCP["MCP target derivation<br/>→ candidate values[]"]
|
||
Bash["Bash command decomposition<br/>→ top-level commands[]<br/>→ most restrictive wins"]
|
||
Skill["Skill name extraction<br/>→ skill name"]
|
||
PathGate["Cross-cutting path gate<br/>(all file access: tools + bash)<br/>→ most restrictive wins"]
|
||
ExtDir["External directory detection<br/>(tree-sitter-bash AST for bash; direct path for tools)<br/>→ normalized path<br/>(Pi infrastructure reads auto-allowed before gate)"]
|
||
end
|
||
|
||
PathGate --> E
|
||
PreProcess --> E
|
||
```
|
||
|
||
The `Agent frontmatter` input (`AF`) is the per-agent override layer.
|
||
It only carries data when an external multi-agent extension is active (see design principle 9): the package resolves the active agent's name from a generic `<active_agent>` signal, then reads the `permission:` sub-document of that agent's definition file at `<cwd>/.pi/agents/<name>.md` (project) or `<agentDir>/agents/<name>.md` (global).
|
||
The package does not discover or enumerate agents — it reads one sub-document by name, on demand — and the `<cwd>/.pi/agents` location is a Pi platform convention this package encodes independently (no dependency on pi-subagents, ADR 0002).
|
||
|
||
## Config format
|
||
|
||
```jsonc
|
||
{
|
||
"permission": {
|
||
"*": "ask",
|
||
"read": "allow",
|
||
"bash": { "*": "allow", "git *": "allow", "npm *": "allow", "rm *": "deny" },
|
||
"mcp": { "*": "ask", "exa:*": "allow" },
|
||
"skill": { "*": "ask", "librarian": "allow" },
|
||
"path": { "*": "allow", "*.env": "deny" },
|
||
"external_directory": "ask"
|
||
}
|
||
}
|
||
```
|
||
|
||
Each top-level key in `permission` is a surface name.
|
||
A string value is shorthand for `{ "*": action }` (surface-level catch-all).
|
||
An object value maps patterns to actions.
|
||
`permission["*"]` is the universal fallback.
|
||
|
||
### Normalization to Rule[]
|
||
|
||
`normalizeFlatConfig` (`src/normalize.ts`) flattens each `permission` entry into `Rule`s: a string value expands to a single surface catch-all (`{ surface, pattern: "*", action }`), and an object value expands each `pattern → action` pair to one `Rule`.
|
||
|
||
## MCP pre-processing
|
||
|
||
MCP is the one surface that requires pre-processing **before** evaluation.
|
||
The multi-name target derivation stays, but it feeds candidate values into `evaluate()` rather than a separate code path:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Input["MCP tool call input"] --> Derive["createMcpTargets(input)"]
|
||
Derive --> Candidates["[exa_search, exa:search, exa, search, mcp_call]"]
|
||
Candidates --> Loop{"For each candidate"}
|
||
Loop --> Eval["evaluate('mcp', candidate, rules)"]
|
||
Eval --> Found{"Explicit match?"}
|
||
Found -->|Yes| Return["Return rule"]
|
||
Found -->|No| Next["Next candidate"]
|
||
Next --> Loop
|
||
Loop -->|Exhausted| Fallback["evaluate('mcp', '*', rules)<br/>(hits synthesized default)"]
|
||
```
|
||
|
||
The priority ordering of candidates is preserved.
|
||
The evaluation function is unchanged - MCP just calls it multiple times with different values.
|
||
MCP target derivation helpers live in `src/access-intent/mcp-targets.ts`.
|
||
Input normalization for all surfaces lives in `src/access-intent/input-normalizer.ts`.
|
||
|
||
### Path-bearing tool normalization
|
||
|
||
Per-tool path patterns — e.g. `"read": { "*": "allow", "*.env": "deny" }` — are evaluated via the `access-path` intent the per-tool gate emits ([#502]).
|
||
When the pipeline calls `resolvePerToolCheck`, a present `input.path` triggers `normalizer.forPath(path)` and an `access-path` intent on the tool-name surface; the resolver unwraps it to `path-values` carrying the lexical ∪ canonical alias set before the manager evaluates the rule.
|
||
When `input.path` is missing or empty, the pipeline falls back to a `tool` intent, which `normalizeInput` collapses to `["*"]` (surface catch-all).
|
||
Path alias derivation (home-expansion, cwd-relative aliases) lives in `getPathPolicyValues` / `AccessPath` — not in `normalizeInput`, which no longer touches path surfaces (#504).
|
||
`getToolPermission()` is unaffected — it always evaluates with `"*"` to determine whether to inject the tool at agent start.
|
||
|
||
The cross-cutting `path` and `external_directory` gates extract paths for **extension and MCP tools too** (#352): `describePathGate` and `describeExternalDirectoryGate` call `getToolInputPath`, which reads `input.path` for built-ins, `input.arguments.path` for MCP, and a registered `ToolAccessExtractor` (or the default `input.path` convention) for any other tool.
|
||
The extractor registry (`src/tool-access-extractor-registry.ts`) is created once in `index.ts` and shared: its lookup side is threaded into `ToolCallGatePipeline`, and its registrar side is exposed cross-extension via `PermissionsService.registerToolAccessExtractor`.
|
||
Per-tool path maps for extension tools (a custom extractor key per tool) are a deferred follow-up.
|
||
|
||
On the bash side, which argument tokens count as filesystem operands is settled by [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): candidacy comes from the filesystem (a bare token is a path candidate iff it names an existing entry), the decision comes from explicit rules or the external boundary, and the ADR names both what the projection guarantees and which gaps are accepted residuals rather than bugs.
|
||
A plain `$HOME` / `${HOME}` / `$PWD` / `${PWD}` reference is resolved at token collection, upstream of classification, so an expanded token is gated exactly as its literal spelling; the resolvable set is closed at those two names by the same ADR.
|
||
|
||
## Session approvals: the cache-miss model
|
||
|
||
Session rules are stored as `Ruleset` and are generalized to all surfaces.
|
||
|
||
`evaluate()` is a **lookup** against cached decisions.
|
||
When no rule matches (or the matching rule says "ask"), the system has a cache miss - it needs the human oracle to produce a decision.
|
||
|
||
The human's response is simultaneously:
|
||
|
||
1. **The answer** for this request (allow or deny).
|
||
2. **A rule** that can be cached for future lookups.
|
||
|
||
The dialog determines **persistence** - where the rule lives:
|
||
|
||
```text
|
||
evaluate(surface, value, composedRules)
|
||
│
|
||
├── match.action = "allow" → proceed (cache hit)
|
||
├── match.action = "deny" → block (cache hit)
|
||
│
|
||
└── match.action = "ask" → cache miss, query oracle
|
||
│
|
||
▼
|
||
Dialog: "[surface] wants to [value]"
|
||
│
|
||
├── "Yes" → allow this request (no persistence)
|
||
├── "Yes, for session" → allow + store in session layer
|
||
│ (future lookups hit without asking)
|
||
├── "No" → deny this request (no persistence)
|
||
└── (future: "Always") → allow + store in config layer (disk)
|
||
```
|
||
|
||
### Pattern suggestions
|
||
|
||
When prompting, each surface suggests a **pattern** for the "for session" option.
|
||
The pattern determines what class of future requests auto-approve:
|
||
|
||
| Surface | Input value | Suggested session pattern | Mechanism |
|
||
| ---------------------- | --------------------------- | --------------------------- | ------------------------ |
|
||
| bash | `git checkout main` | `git checkout *` | Arity table |
|
||
| bash | `npm run dev` | `npm run dev` | Arity table |
|
||
| tool (read/write/etc.) | tool surface itself | `*` (all uses of that tool) | Tool-level |
|
||
| mcp | `exa:search` | `exa:*` | Server-level wildcard |
|
||
| skill | `librarian` | `librarian` | Exact name |
|
||
| external_directory | `/other/project/src/foo.ts` | `/other/project/*` | Directory prefix as glob |
|
||
|
||
The suggestion is shown in the dialog text so the user sees what they're approving:
|
||
|
||
```text
|
||
● Allow once
|
||
● Allow "git checkout *" for this session
|
||
● Deny
|
||
```
|
||
|
||
### Implementation
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Gate as Elicitor (ask-path)
|
||
participant Eval as evaluate()
|
||
participant Session as Session Rules (Ruleset)
|
||
|
||
Gate->>Eval: evaluate("bash", "git status", composedRules)
|
||
Eval-->>Gate: { action: "ask" } (cache miss)
|
||
Gate->>User: "Allow 'git status'? [Once / Session: 'git status*' / Deny]"
|
||
User-->>Gate: "Session"
|
||
Gate->>Session: append { surface: "bash", pattern: "git status*", action: "allow" }
|
||
|
||
Note over Gate,Session: Next similar call - cache hit
|
||
Gate->>Eval: evaluate("bash", "git status --short", composedRules incl. session)
|
||
Eval-->>Gate: { action: "allow" } (matched session rule)
|
||
Note over Gate: No prompt needed
|
||
```
|
||
|
||
## Prompt presentation
|
||
|
||
What a prompt must show, what a renderer may elide, and what bounds its size are settled by [ADR 0011](../decisions/0011-prompt-presentation-contract.md).
|
||
The contract in one line: **the payload is complete, and elision is a property of a render, never of the payload**.
|
||
|
||
A gate emits structured facts rather than a sentence.
|
||
The payload's `request` group — requester and forwarded-ness, tool name and invoked tool name, gate surface and matched rule, the decision-relevant value, and for bash the unit that will actually run — is never elided by any renderer.
|
||
`evidence` is complete on the payload and elided to fit each renderer's budget, with the elision marked but uncounted; an operator must still be able to reach the complete information while the decision is pending.
|
||
The dialog is bounded by a row budget plus a per-field width cap, the review log by its own configured limits, and the `permissions:ui_prompt` broadcast receives the `request` facts only — the narrowest renderer, because the bus is the one channel an extension observes without the operator having named it.
|
||
Denial text is a fifth render of the same facts under one extra rule: it identifies the call rather than reproducing it, since the agent already holds its own tool input.
|
||
|
||
The payload exists, and the human-facing renderers are bounded.
|
||
Every gate emits a `PromptPayload` (`src/presentation/`), and `PromptPermissionDetails` requires one, so the six former assembly sites are gone.
|
||
`renderPromptDialog` renders it for the inline dialog and the `select`/`input` fallback under `promptMaxRows` plus `promptFieldMaxWidth`, and `Ctrl+O` expands the dialog to the complete request.
|
||
The cap applies to the `request` facts too: never elided means never *omitted* — a long one is shortened, marked, and reachable in full rather than dropped.
|
||
Without that reading a bounded render is unreachable, since the decision-relevant value is itself the pathological field in the reported case ([#710]).
|
||
A fact an adjacent line already states is not repeated — a bash ask's gate surface is its tool name, and a path ask's is the word its value line is labelled with — so the render spends a line only where it adds something.
|
||
That is a redundancy rule, not an elision: the fact is still on screen, which is what §3 requires.
|
||
|
||
The two cross-boundary contracts now carry facts rather than prose.
|
||
The forwarded-request wire carries the child's `PromptPayload`, so the serving node renders the child's own facts under the *parent's* budget — a forwarded bash ask reads `command : …` exactly as a local one does, and `kind: "forwarded"` narrows to meaning one thing: this ask arrived without a payload.
|
||
`permissions:ui_prompt` carries `request`, the payload's invariant core, and no evidence at all, which makes the bus the narrowest renderer (ADR 0011 §6): any loaded extension observes it without the operator having named that extension.
|
||
`toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` are deprecated and ignored, superseded by the renderer budgets.
|
||
|
||
The last two consumers are renderers too, so the flat `message` string is gone.
|
||
The agent-facing text identifies a refused call rather than reproducing it (§7): it names the surface, the tool, the rule with its nested context, the flagged path or target or skill, and the operator's or human's reason — never the bash command, which is the payload that took over the viewport in [#710] and the agent's context window on every denial.
|
||
The flagged element is agent input, so it is capped rather than structurally bounded; naming it is what makes a denial correctable, since which of a call's operands a rule fired on is below tool-call granularity and the agent cannot recover it from its own arguments.
|
||
The review log persists the payload's request facts rather than the prompt sentence — stamped by `GateRunner` beside the request id, so no gate can forget them — and every string it writes is narrowed to `reviewLogFieldMaxWidth`.
|
||
That bound lives in `writeLine` beside the key-name mask, which makes the log's growth a decision the operator makes rather than a consequence of how long a command happened to be.
|
||
ADR 0011 records what each dependent item becomes under the contract.
|
||
|
||
## Two-phase checking
|
||
|
||
### Phase 1: Tool filtering (`before_agent_start`)
|
||
|
||
`shouldExposeTool` (`src/handlers/before-agent-start.ts`) calls `evaluate(toolName, "*", rules)` and exposes the tool unless the surface-level result is `deny` — "is this tool denied regardless of specific input?"
|
||
|
||
### Phase 2: Invocation gating (`tool_call`)
|
||
|
||
The gate pipeline (`src/handlers/gates/`) normalizes the input to `(surface, value)`, evaluates it against the composed ruleset, and acts on the result: `allow` proceeds, `deny` blocks, and `ask` elicits from the session's `Authorizer` — a persisted "session" decision appends a `Rule` to `sessionRules` so the next similar call is a cache hit.
|
||
|
||
Same `evaluate()`, same ruleset.
|
||
The only surface-specific logic is input normalization (what `surface` and `value` to look up) and pattern suggestion (what glob to offer for "session" approval).
|
||
|
||
`checkPermission()` uses a single evaluate path: `normalizeInput()` → `evaluateFirst()` → `deriveSource()` → single result object.
|
||
|
||
## Subagent detection and permission forwarding
|
||
|
||
When `ask`-state permissions arise in a headless subagent child process, the extension forwards the dialog to the parent session rather than silently denying.
|
||
This requires two detections:
|
||
|
||
1. **Is the current process a subagent?**
|
||
- `isSubagentExecutionContext()` in `src/authority/subagent-context.ts`.
|
||
2. **What is the parent session ID?**
|
||
- `resolvePermissionForwardingTargetSessionId()` in `src/authority/permission-forwarding.ts`.
|
||
|
||
### Known extension env var inventory
|
||
|
||
| Extension | Child-process env vars | Parent-session env var |
|
||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
||
| pi-agent-router (original) | `PI_IS_SUBAGENT`, `PI_SUBAGENT_SESSION_ID`, `PI_AGENT_ROUTER_SUBAGENT` | `PI_AGENT_ROUTER_PARENT_SESSION_ID` |
|
||
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_RUN_ID`, `PI_SUBAGENT_CHILD_AGENT`, `PI_SUBAGENT_DEPTH` | none set (see #98) |
|
||
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()`; the maintained `my-pi` fork uses the registry lifecycle | n/a |
|
||
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | `PI_SUBAGENT_NAME`, `PI_SUBAGENT_ID`, `PI_SUBAGENT_SESSION`, `PI_SUBAGENT_ACTIVITY_FILE` | none set (see #98) |
|
||
|
||
### Detection (`isSubagentExecutionContext`)
|
||
|
||
`isSubagentExecutionContext()` checks three sources in priority order:
|
||
|
||
1. **Explicit registry** - a compatible in-process spawner (`@gotgenes/pi-subagents` or the maintained `my-pi` Tintinweb fork) emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously.
|
||
The registry (keyed by **child session id**) is checked first.
|
||
Each concurrent sibling child of the same parent receives a unique session id from `sessionManager.newSession()`, so siblings occupy distinct keys - one sibling's `disposed` event cannot evict another's entry (fixes #298).
|
||
The registry is a process-global singleton (via `getSubagentSessionRegistry()`, backed by `globalThis` + `Symbol.for()`) because each session's `ResourceLoader` creates its own `pi.events` bus: the parent's instance registers the child over the parent bus, while the child's separate jiti instance reads the same global store to detect itself and resolve its forwarding target.
|
||
2. **Env vars** (`SUBAGENT_ENV_HINT_KEYS`) - returns `true` when any key is set to a non-empty, non-whitespace value.
|
||
Used by process-based subagent extensions.
|
||
3. **Filesystem path** - session-directory path-based fallback (child session dir is nested under `subagentSessionsDir`).
|
||
|
||
### Parent-session resolution (`resolvePermissionForwardingTargetSessionId`)
|
||
|
||
`resolvePermissionForwardingTargetSessionId()` checks two sources in priority order:
|
||
|
||
1. **Explicit registry** - if the caller provides a `sessionId` and `registry`, the registry entry's `parentSessionId` is returned when present.
|
||
Used by in-process subagent extensions.
|
||
2. **Env vars** (`SUBAGENT_PARENT_SESSION_ENV_CANDIDATES`) - iterates candidates and returns the first non-empty, non-`"unknown"` value.
|
||
Used by process-based subagent extensions.
|
||
|
||
Neither nicobailon nor HazAT sets a parent-session env var today, so forwarding still fails for those extensions with an explicit log message pointing to #98.
|
||
Adding a new env var candidate when an extension adopts the convention is a one-line change to the array.
|
||
|
||
### In-process case (resolved)
|
||
|
||
In-process subagent extensions call `createAgentSession()` directly - no child process is spawned and no env vars are ever set.
|
||
`@gotgenes/pi-subagents` and the maintained `my-pi` Tintinweb fork publish `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously.
|
||
The registry is process-global (see `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`) so the child's separate jiti instance reads the same store as the parent.
|
||
See `src/authority/subagent-registry.ts` and [Subagent Integration](../subagent-integration.md) for details.
|
||
|
||
### External convention guide
|
||
|
||
A [permission frontmatter convention guide](../guides/permission-frontmatter-for-subagent-extensions.md) documents how upstream subagent extensions can adopt the `permission:` frontmatter key as a shared convention.
|
||
This is a documentation-only proposal - no code dependency is required.
|
||
The guide covers the two-layer model, flat format reference, composition examples, and the optional event bus runtime integration.
|
||
|
||
## Cross-extension service accessor
|
||
|
||
The primary cross-extension API is a `Symbol.for()`-backed service object on `globalThis`.
|
||
|
||
Pi's extension loader creates a fresh jiti instance per extension with `moduleCache: false`, isolating module-scoped state.
|
||
`Symbol.for()` and `globalThis` are process-global by spec, so they survive this isolation.
|
||
|
||
The extension publishes a `PermissionsService` object via `publishPermissionsService()` at `session_start`, gated so an in-process subagent child does not clobber the parent's service (#302).
|
||
Other extensions retrieve it with `getPermissionsService()` from `import("@gotgenes/pi-permission-system")`.
|
||
The `package.json` `exports` field's `default` condition points to `src/service.ts`, which contains the interface, the accessor functions, and the `Symbol.for()` key - no extension machinery.
|
||
The `types` condition instead resolves to a bundled `dist/public.d.ts` (built by `rollup-plugin-dts` from `rollup.dts.config.mjs`, published via `prepack`) so a downstream consumer's `tsc` never follows the raw `#src/*` module graph - only the `default` condition (the jiti runtime) reads `src/` directly (#592).
|
||
|
||
The `PermissionsService` interface exposes five methods:
|
||
|
||
- `checkPermission(surface, value?, agentName?)` - full policy query.
|
||
- `getToolPermission(toolName, agentName?)` - tool-level permission state (`allow`/`deny`/`ask`) for pre-filtering.
|
||
- `registerToolInputFormatter(toolName, formatter)` - register a custom ask-prompt preview for a tool name; returns a disposer (#283).
|
||
- `registerToolAccessExtractor(toolName, extractor)` - declare the filesystem path a non-conventional tool accesses, so the cross-cutting `path`/`external_directory` gates see it; returns a disposer (#352).
|
||
- `registerAuthorizer(name, authorize)` - register a named live-authority chain link (`allow | deny | defer`, ADR 0007 §4); decides nothing until the operator names it in `authorizerChain` config, and every verdict is capped by the bounded-delegation checkpoint; returns a disposer.
|
||
|
||
`permissions:decision` and `permissions:ui_prompt` broadcasts remain on the event bus - fire-and-forget observation is the right abstraction for those channels ([#531] removed the event-bus RPC channel; the service accessor is now the sole cross-extension policy/prompt surface).
|
||
|
||
## The authority model
|
||
|
||
This section records the organizing concept the package is built around — the spine the elicitation, forwarding, and yolo machinery collapse into — plus the still-open directions that extend it.
|
||
It is current state, not a target: the `Authorizer` interface, its three implementations, once-per-activation selection, `canConfirm()`'s dissolution, serving-as-resolution, human-selectable grant-scope, and the `authority/` directory migration all shipped in Phase 9 (see [history/phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md) for why the spine is the correct model of the `@gotgenes/pi-subagents` integration — the anonymous cross-session-authority recursion behind the [#296]/[#298]/[#302] bug history — and not merely an internal tidy).
|
||
Of the ["beyond the target"](#beyond-the-target-a-non-deterministic-access-intent-classifier) extension points below, the model-triage `Authorizer` chain is now implemented (Phase 12; [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md)), and its named-link registration subsumes the pluggable escalation seam; the deny-first slice is dogfooded by `packages/pi-permission-model-judge`, and the allow-capable opaque-bash adjudicator ([#620]) remains the sole open Track B slice.
|
||
A non-deterministic access-intent classifier remains aspirational.
|
||
|
||
### The spine
|
||
|
||
Every action resolves against an **authority** — an entity empowered to permit or forbid it.
|
||
The only questions are *which* authority and how we reach it.
|
||
|
||
This sharpens principle 8.
|
||
That principle calls the human "the oracle," borrowing the computer-science term for a black box consulted for an answer the system cannot compute.
|
||
But a permission decision is not epistemic (who *knows* the answer); it is deontic (who has the *right* to decide).
|
||
If a bystander happened to know what the user wanted, their saying "allow" would authorize nothing.
|
||
What makes a decision binding is authority, not knowledge — so the organizing concept is authority, and the entity that holds it is an **`Authorizer`**.
|
||
The human is merely the `Authorizer` at the interactive root; another agent can hold the role equally well.
|
||
|
||
### Authority lives in three places
|
||
|
||
1. **Recorded authority** — the ruleset.
|
||
Config (durable, on disk), session rules (this session), and synthesized defaults/baseline are all prior rulings.
|
||
`evaluate()` *is* "consult recorded authority": an `allow` or `deny` means recorded authority is sufficient, and the decision is final.
|
||
2. **Live authority** — reached only on `ask`, when recorded authority is silent.
|
||
An entity empowered to rule *now*, reached through one of three channels (below).
|
||
3. **Absent authority** — nothing recorded, nothing reachable.
|
||
Least privilege applies: no authority means the action is unauthorized, so it is denied.
|
||
|
||
The three are one thing at different lifetimes.
|
||
A live ruling, once persisted, *becomes* recorded authority — principle 8's "their decision is a rule."
|
||
The "for this session" dialog option writes a session rule; a future "always" writes config.
|
||
|
||
### The `Authorizer` role
|
||
|
||
On `ask`, the gate escalates to **one `Authorizer`, selected once per session from context**, and is told the decision.
|
||
|
||
1. **`LocalUserAuthorizer`** — the session has UI; prompt the human here.
|
||
2. **`ParentAuthorizer`** — the session is a subagent; escalate up the tree to the parent's authority.
|
||
3. **`DenyingAuthorizer`** — no authority is reachable; deny (least privilege).
|
||
|
||
There is no "can anyone answer" pre-check.
|
||
`canConfirm()` — today a boolean smeared across the gateway, prompter, and forwarder — dissolves: every `Authorizer` answers, the `DenyingAuthorizer` by denying.
|
||
The three context predicates (`hasUI`, `isSubagent`, yolo) are evaluated once, at selection, instead of repeatedly down the prompt path.
|
||
|
||
```text
|
||
evaluate(action, recorded authority)
|
||
├─ allow / deny ------------------> decided (recorded authority sufficient)
|
||
└─ ask (recorded authority silent)
|
||
└─ escalate to the session's Authorizer
|
||
├─ LocalUserAuthorizer -> prompt the human here
|
||
├─ ParentAuthorizer -> forward up the tree, await the parent's ruling
|
||
└─ DenyingAuthorizer -> deny (no authority reachable)
|
||
|
|
||
(a persisted ruling becomes recorded authority)
|
||
```
|
||
|
||
### The recursion
|
||
|
||
Authority is delegated **down** the session tree: the human drives the root, which spawns subagents that hold no inherent authority to approve a novel action.
|
||
So an `ask` a subagent cannot answer **escalates up** to where authority resides.
|
||
Permission-system instances form a tree mirroring the session tree, and `ParentAuthorizer` is the edge that routes a child's escalation toward the human at the root.
|
||
This is the same recursion pi-subagents describes (a subagent is a child Pi), viewed from the permission system's side: the package is itself one of the hooks on that child, and it recurses by forwarding.
|
||
|
||
### Reconstruction fidelity at the serving node
|
||
|
||
The courier hop carries facts, not judgment — but what the serving node reconstructs from a forwarded request differs by audience, and the two directions are the same rule applied to different trust boundaries.
|
||
|
||
An **in-process seam** — the `Authorizer` chain, reached through `PromptPermissionDetails` — receives the full child-fixed fact set.
|
||
A chain link is operator-opted-in via `authorizerChain` and must decide from evidence, not from parsed display text or a parent-side re-derivation of the child's path ([ADR 0008](../decisions/0008-cross-session-access-intent.md) forbids the latter outright).
|
||
The bounded-delegation checkpoint reads the same facts, so a forwarded ask is capped on the gate surface exactly as a local one is ([ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) §5).
|
||
|
||
A **cross-extension broadcast** — `permissions:ui_prompt` / `permissions:decision` on `pi.events` — receives the minimum needed to stay correlatable, because any loaded extension can observe it.
|
||
|
||
Maximum fidelity to the decider; minimum disclosure to the observer.
|
||
Requester identity (`requesterCwd`, `principal`) crosses to neither: it is the serving node's own resolution input (ADR 0008 §3) and stays on the wire object, with the ask details carrying only the `forwarding` provenance.
|
||
|
||
### yolo is recorded authority
|
||
|
||
yolo is not a channel and not a live concern — it is a standing authorization, and it belongs in the ruleset, not in the prompt path.
|
||
It is a composition-stage rewrite: when enabled, every `ask` action in the composed ruleset is rewritten to `allow`, tagged `origin: "yolo"` so the review log still distinguishes a yolo grant from a policy allow.
|
||
|
||
```typescript
|
||
const effective = yolo
|
||
? composed.map((r) => (r.action === "ask" ? { ...r, action: "allow", origin: "yolo" } : r))
|
||
: composed;
|
||
```
|
||
|
||
This is faithful to current behavior exactly: explicit `deny` rules are not `ask`, so they pass through untouched — yolo suppresses prompts but **preserves hard denies**.
|
||
It honors principle 5 (defaults are rules; no side-channel fallbacks): `evaluate()` runs pure over the rewritten ruleset, and the prompt path loses all yolo knowledge (`shouldAutoApprovePermissionState` and `canResolveAskPermissionRequest`'s yolo arm dissolve).
|
||
|
||
The ruleset is the whole story for asks the ruleset produces.
|
||
An `ask` synthesized *after* resolution is not one: the bash wrapper floor (#481, #490) and the fail-closed `<unparseable-bash-command>` sentinel (#452) are properties of a parsed command unit, not of a pattern, so there is no rule for the rewrite to touch and they reached the prompter under yolo (#712).
|
||
The reconciliation has exactly one home — `resolveYoloGrant` at `GateRunner`'s auto-approve fast path, the single choke point every gate passes through before escalating — so the contract "an `ask` never reaches `PermissionPrompter` under yolo" holds structurally for whatever floor is added next.
|
||
It is the same deny-preserving shape as the rewrite: a `deny` is not an `ask`, so it matches neither arm.
|
||
A future "disable everything" mode — overriding denies too — would be a *different*, deliberately named operation: appending a final `{ surface: "*", pattern: "*", action: "allow" }` rule (last-match-wins).
|
||
It is not built, and it would be requested by name, never conflated with yolo.
|
||
|
||
### Fail-closed on an invalid non-global scope
|
||
|
||
The mirror image of the yolo rewrite.
|
||
When a non-global config scope (project, agent, or project-agent) is present but fails to load or validate, the loader marks it invalid (`ScopeConfig.invalid`) instead of silently substituting an empty scope.
|
||
At composition the manager floors every `allow` in the composed ruleset to `ask`, tagged `origin: "fail-closed"`, so a permissive rule inherited from a lower-precedence scope cannot remain effective behind a higher scope that was meant to tighten it (#646).
|
||
|
||
```typescript
|
||
const effective =
|
||
failClosedScopes.length > 0
|
||
? composed.map((r) => (r.action === "allow" ? { ...r, action: "ask", origin: "fail-closed" } : r))
|
||
: composed;
|
||
```
|
||
|
||
Like yolo it is deny-preserving (only `allow` is touched) and applied at composition, so the display surfaces (`getComposedConfigRules`, `getToolPermission`) reflect the clamp too.
|
||
Global is excluded — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
||
The two overlays stack in order: fail-closed floors `allow`→`ask` first, then yolo (if enabled) rewrites `ask`→`allow`, so an explicit yolo opt-in still wins.
|
||
|
||
### Discriminating delegation: a model `Authorizer`
|
||
|
||
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.
|
||
A model (e.g. Claude Haiku) can hold an `Authorizer` role on the same terms: it is live authority, so it never touches `evaluate()` or the deterministic core.
|
||
|
||
The design is settled in [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md); the essentials follow.
|
||
|
||
**The live-authority layer is a Chain of Responsibility.**
|
||
Each link returns `allow | deny | defer`; on `defer` the next link decides.
|
||
The chain ends at a **terminal that cannot defer** — today the human (`LocalUserAuthorizer`), the headless `DenyingAuthorizer`, or `ParentAuthorizer` (terminal for its node, forwarding up to the parent node's chain — the [recursion](#the-recursion) above).
|
||
The invariant is type-level: a terminal returns only `allow | deny`, so a deferring link cannot occupy the terminal slot.
|
||
`selectAuthorizer` becomes the terminal-selection step of `composeAuthorizerChain` — registered non-terminal links, then the context-selected terminal.
|
||
|
||
**One chain per node.**
|
||
An ask is adjudicated by exactly one node's chain: the node whose terminal decides it (ADR 0007 §7).
|
||
A subagent node's terminal relays the ask to a serving node, which escalates it through *its* chain over the same child-fixed facts — so a relaying node resolves no links, and records `authorizer_chain_delegated` rather than reporting each configured name as a fail-safe skip.
|
||
An adjudicating node records `authorizer_chain_resolved` with the names it consulted, since a deferring link decides nothing and otherwise leaves no evidence it ran.
|
||
|
||
```text
|
||
ask -> [ model-judge link ] --defer--> … --defer--> [ terminal: human | Parent | Denying ]
|
||
├─ deny (with teaching reason) -> denied
|
||
├─ allow (slice 2, if not excluded) -> permitted
|
||
└─ defer -> next link
|
||
```
|
||
|
||
**The model judge is a non-terminal link**, not a decorator or a fourth channel.
|
||
It reviews an `ask`, decides the ones it is confident about, and defers the rest to its successor — a middle rung between prompt-everything and allow-everything.
|
||
Denies are decided by recorded authority and structurally never reach an `Authorizer`, so a model link cannot grant a hard deny; the safeguard for a sensitive resource stays an explicit `deny` rule, which survives the model just as it survives the yolo rewrite.
|
||
|
||
The verdict range is `allow | deny | defer` — a superset of the earlier allow-or-escalate framing — because the first use case is **deny-first**.
|
||
A light model reviews `external_directory` asks, denies an errant "typo" path with a teaching `reason` (wrong path; correct location) so the invoking model self-corrects, and defers everything else.
|
||
A second use case adjudicates **opaque bash**: the model decomposes a `bash -c "…"` / `eval` command and queries the deterministic engine per sub-command through an injected, narrow `PermissionQuery` (never a reach-through to `PermissionsService`), allowing only what the engine already grants for the pieces it identifies.
|
||
The two are one link on a **capability gradient**: the deny/defer reviewer is strictly more restrictive and ships first; the allow-capable adjudicator loosens privilege and is gated behind the full envelope (hard exclusions, audit `origin: "authorizer:model"`, non-persistence, off by default), because its safety property holds only if the model's decomposition is faithful.
|
||
|
||
Registration mirrors `registerToolAccessExtractor`: a downstream extension offers a **named** capability (`registerAuthorizer("model-judge", …)`) on `permissions:ready`, and this package makes no LLM call itself.
|
||
Three invariants govern the seam: config order (not registration order) fixes the security-relevant chain order; a missing configured link is skipped fail-safe (more prompting, never less); and **registration alone grants no authority** — a link decides nothing until the operator names it in the `authorizerChain` config (opt-in).
|
||
Bounded delegation is operator config this package enforces at a checkpoint that downgrades an excluded-surface `allow` to `defer`, with `external_directory` and secret-shaped `path` always excluded; the model's provider/prompt/threshold live in the downstream extension's own config.
|
||
|
||
This is the principled successor to the per-command argument-position work deferred from [#509].
|
||
The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables.
|
||
This split is the layering principle of [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): the deterministic layer biases toward surfacing because over-suppression is unrecoverable, and the judge absorbs the surplus.
|
||
The two compose cleanly because a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.
|
||
|
||
**Dogfooded:** a first-party monorepo package (`packages/pi-permission-model-judge`) implements the deny-first typo-path reviewer against the real seam, so `registerAuthorizer` is born consumed (the [#267] vacant-surface guard).
|
||
|
||
### Resolved direction
|
||
|
||
These were the open decisions; they are now settled and shipped (full rationale in [history/phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md)).
|
||
|
||
1. **Serving is resolution.**
|
||
A serving node runs `evaluate()` against its recorded authority then escalates to its own `Authorizer` on `ask`, carrying the forwarded ask's provenance as data so the `permissions:ui_prompt` broadcast stays non-degraded.
|
||
2. **Multi-level escalation: admitted, not shipped.**
|
||
A middle node's chain terminates in a `ParentAuthorizer`, so re-escalation needs no special-casing; the tree is depth-2 today (pi-subagents' recursion guard), and a one-hop canary flags any future break.
|
||
3. **Full delegation of authority down the tree.**
|
||
A subagent inherits its ancestors' `allow`/`deny` rules and yolo; because yolo is deny-preserving, the safeguard for a cheaper delegate is an explicit `deny` in its per-agent frontmatter, not an `ask`.
|
||
4. **Grant scope is human-selectable.**
|
||
Approving a forwarded request "for this session" offers root / parent / requesting-subagent scope (requesting subagent pre-selected); "parent" and "root" coincide until trees deepen.
|
||
|
||
### Remaining design work
|
||
|
||
**Access-intent extraction** is the one genuinely open piece, and the foundation for the path surface of the decisions above.
|
||
The package's center of mass is not the decision engine (tiny, pure) but turning `(toolName, input)` into "what is being accessed" — bash decomposition, MCP target derivation, path extraction, external-directory detection.
|
||
This is a distinct domain (access intent) that gates should *emit* and a single `resolve(intent)` should answer, so adding a gate cannot widen the resolver surface.
|
||
The [#393] false-green (a stubbed-but-unrouted resolver method silently passing `allow`) was the probe pointing at it: the resolver surface was `resolve` + `resolvePathPolicy`, widening per gate, until Phase 6 Step 6 ([#478]) collapsed it to one `resolve(intent)`.
|
||
[#418] is a second probe, from the access-path side: both external-directory gates matched config patterns against the symlink-resolved path because a single `string` carries a path that is simultaneously a containment value (canonical, for the outside-CWD boundary) and a match value (lexical, as the user typed it), with no type distinction — so the canonical form leaked into matching and defeated a configured `/tmp/*` allow.
|
||
The same conflation lived in `BashProgram.externalPaths(): string[]`, which returned only the canonical form and so lost the typed value the matcher needed.
|
||
The fix's `getExternalDirectoryPolicyValues` helper (the union of lexical aliases and the canonical path) was the embryo of the access-path: `AccessPath` ([#476]) now holds both forms behind distinct `matchValues()` and boundary accessors, making the misuse a compile error; `BashProgram.externalPaths()` now returns `AccessPath[]` and one external-directory policy check can replace the two parallel gates that independently acquired this bug.
|
||
The tractable first slice was the access-path value object seeded by [#418]: it removed the path-representation conflation and the duplicate external-directory gate without waiting on principal identity or cross-session portability.
|
||
Principal identity and path portability across cwds — a subagent in a `pi-subagents-worktrees` worktree resolves paths against a different root than the parent — are now settled: [ADR 0008](../decisions/0008-cross-session-access-intent.md) (Phase 12) fixes a path-shaped ask's portable meaning at the child (the child's lexical ∪ canonical `matchValues()` plus canonical `boundaryValue()`), carries it onto the forwarded wire as `ForwardedAccessIntent`, and makes serving agent-scoped (`requesterAgentName` decision-participating).
|
||
A forwarded ask now resolves against the child-fixed alias set rather than a re-derivation through the parent's `PathNormalizer`/cwd.
|
||
With principal identity and path portability delivered, this domain has no further genuinely open piece; a non-path serving refinement (a per-surface `Authorizer` chain exclusion beyond `external_directory`/secret-shaped `path`) remains a candidate but is not scheduled.
|
||
|
||
### Beyond the target: a non-deterministic access-intent classifier
|
||
|
||
This is a **more distant** direction than the target above — noted as a candidate extension point, not planned work.
|
||
|
||
Access-intent extraction is deterministic by design: `(toolName, input)` becomes "what is being accessed" through bash decomposition, MCP target derivation, and path rules.
|
||
A second, independent place non-determinism could one day enter is a model that *classifies* access intent **before** `evaluate()` — deciding, for instance, that `id_rsa` in `git grep id_rsa` is a search pattern rather than a file, so no path candidate is emitted at all.
|
||
|
||
The classifier differs from the [`ModelTriageAuthorizer`](#discriminating-delegation-a-model-authorizer) in *where the model sits*.
|
||
The classifier feeds **recorded** authority — it shapes the intent `evaluate()` rules on — whereas the Authorizer holds **live** authority and answers the `ask`.
|
||
A wrong classifier call is a misread of what is being accessed; a wrong Authorizer call is a mis-granted decision.
|
||
Because the classifier changes the *input* to the deterministic core, it weakens the "same `(toolName, input)` yields the same ruling" property more subtly than the Authorizer does — the model output becomes part of the intent — so it warrants its own decision record and is deliberately out of scope for the current target.
|
||
The access-intent domain the gates emit into is the natural seam for such a pluggable classifier: deterministic today, model-assisted only if and when that trade is made by name.
|
||
|
||
### Beyond the target: a pluggable escalation seam
|
||
|
||
The **registration seam** this section anticipated is now designed: [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) settles the `Authorizer` chain and its named-link registration (`registerAuthorizer`), with the model judge as its first consumer.
|
||
What remains a **more distant** direction — a candidate extension point, not planned work — is applying that same seam to *replace the terminal* (a delegation framework other than pi-subagents, a chat-approval bot, or a remote review surface *as* the authority) and refactoring the built-in subagent integration to register through it.
|
||
|
||
The [#261]/[#267] inversion made pi-subagents pure — it publishes its child lifecycle and knows nothing about consumers ([ADR-0002]) — but the purity is one-sided: this package is the integration owner.
|
||
It knows pi-subagents' event channel names (`subagent-lifecycle-events.ts`), hardcodes an env-hint inventory of known third-party subagent extensions (`SUBAGENT_ENV_HINT_KEYS`), and bakes in a session-directory heuristic.
|
||
Supporting a new delegation framework — or something that is not a subagent extension at all, such as a chat-approval bot or a remote review surface — means editing this package.
|
||
|
||
The subagent machinery decomposes into three roles a seam would name and separate:
|
||
|
||
- **Detection** — is this session a delegated context?
|
||
This is an Authorizer-selection predicate; [#529]'s `SubagentDetection` gives it one owner.
|
||
- **Target resolution** — where does authority live for this session; which node serves the escalation (`resolvePermissionForwardingTargetSessionId` today).
|
||
- **Transport** — how an `ask` travels to that authority and the ruling returns (the file-based request/response polling today; [#530]'s escalation-up role, `ParentAuthorizer` since [#555]).
|
||
|
||
A registered provider is exactly a selection predicate plus a `ParentAuthorizer`-shaped transport: "when my predicate matches this session and recorded authority is silent, escalate through me."
|
||
The `Authorizer` spine is therefore the seam — this direction is the spine's registration story, not a mechanism beside it.
|
||
|
||
Two shapes, the second generalizing the first:
|
||
|
||
1. **A bridge extension** — a third package subscribes to pi-subagents' lifecycle and registers with this package's public seam, leaving both cores pure.
|
||
A dedicated glue extension knowing both ends is the sanctioned complement of the rule against outbound bridges *from a core*.
|
||
2. **A dogfooded provider seam** — this package defines the registration API and implements its own built-in pi-subagents integration through it, the way `registerToolAccessExtractor` / `registerToolInputFormatter` already let extensions plug the gates; third parties register on equal terms and the zero-config default survives.
|
||
|
||
A history guard: this re-introduces an inbound registration surface of the kind [#267] retired.
|
||
It differs in kind — consumer-agnostic, documented for third parties, and consumed by the built-in provider itself, so it cannot go vacant the way the two-method `registerSubagentSession` RPC did.
|
||
|
||
Any design must honor the standing constraints: registration lands synchronously before `bindExtensions()`; cross-session visibility rides `globalThis` + `Symbol.for()` (the [#296] bus-split lesson); a provider is live authority only and never touches `evaluate()`; and a session no provider claims selects `DenyingAuthorizer` — least privilege, unchanged.
|
||
It sequences after the Phase 9 spine and warrants its own decision record.
|
||
|
||
### Naming
|
||
|
||
The concept and the code role take two grammatical forms of one root, each for what it correctly denotes:
|
||
|
||
- **`authority`** (mass noun) — the right to decide; used for the concept ("recorded authority," "where authority lives").
|
||
- **`Authorizer`** (count noun) — the entity that holds it; used for the interface and its implementations.
|
||
|
||
`Authorizer` is domain-idiomatic: AWS Lambda "authorizers" and OAuth's authorization server return allow/deny, so the term already denotes an entity that can refuse.
|
||
|
||
## Module structure
|
||
|
||
```text
|
||
src/
|
||
├── rule.ts Rule type, Ruleset type, evaluate() (takes an injected `PathFlavor` for win32 path-surface case-folding); exports `pathMatchOptions(surface, flavor)`
|
||
├── normalize.ts Config → Ruleset normalization (flat format)
|
||
├── synthesize.ts Universal default + MCP baseline → Ruleset
|
||
├── wildcard-matcher.ts Compiled glob matching. `CompiledWildcardPattern.matches(value)` is the only match surface (no exposed `RegExp`). Constraint: the win32 `windowsSeparators` fold applies to the pattern and the matched value alike, and lives on the compiled pattern so it cannot be half-applied — folding only the pattern makes every forward-slash value unmatchable (#653)
|
||
├── pattern-suggest.ts Per-surface approval pattern suggestions
|
||
├── bash-arity.ts Command arity table for bash pattern suggestions
|
||
├── expand-home.ts `expandHomePath`: `~` / `$HOME` / `${HOME}` expansion for patterns and path values, over one prefix table so the three spellings cannot drift; a prefix is recognized only standalone or before a separator, so `~username` / `$HOMEDIR` / `${HOME:-/tmp}` are left alone
|
||
├── session-approval.ts SessionApproval value object - owns the single/multi-pattern union; exposes representativePattern and toGateApproval()
|
||
├── session-rules.ts Session approval store (Ruleset wrapper); `implements SessionApprovalRecorder`; injected into `GateRunner` as the recorder role
|
||
├── policy-loader.ts PolicyLoader interface + FilePolicyLoader (file I/O, mtime caching); marks a present-but-unloadable non-global scope `invalid` (an absent file stays a plain empty scope) so composition can fail closed
|
||
├── scope-merge.ts Cross-scope permission merge + origin-map bookkeeping
|
||
├── permission-manager.ts Scope loading + rule composition + `check(intent)` (single resolution entry point); delegates I/O to PolicyLoader; floors the composed ruleset `allow`→`ask` (origin `fail-closed`) when a non-global scope is `invalid`, and appends a fail-closed notice to `getConfigIssues`. Constraint: stays string-based — must not import `AccessPath` (the ADR 0002 string boundary, lint-guarded by `no-restricted-imports`)
|
||
├── permission-gate.ts Pure deny/ask/allow gate (injected IO)
|
||
├── permission-resolver.ts `ScopedPermissionResolver` interface - the single `{ resolve(intent) }` role the gate factories / runner / pipeline depend on; `PermissionResolver` concrete class holds `ScopedPermissionManager` + `SessionRules`, owns `resolve(intent)` (unwraps an `access-path` `AccessIntent` via `matchValues()` before calling `manager.check`; the concrete class also accepts a pre-fixed `path-values` intent as a passthrough — the forwarded-serving wire's producer, #597 — while the gate-facing interface stays narrow to `AccessIntent`), raw `checkPermission` (`implements SkillPermissionChecker`, no session rules), `getToolPermission`, and `getConfigIssues`
|
||
├── decision-reporter.ts `DecisionReporter` interface + `GateDecisionReporter` class - owns `SessionLogger` and event bus; writes review-log entries and emits decision events
|
||
├── decision-audit.ts `DecisionRecorder` / `DecisionSummaryWriter` / `AuditLogger` interfaces + `DecisionAudit` class - per-session decision counters; `writeSummary` emits a `permission.session_summary` debug line on shutdown and warns on a `toolCalls != allowed + blocked + errors` invariant violation
|
||
├── session-approval-recorder.ts `SessionApprovalRecorder` interface - records a granted session-scoped approval into the session ruleset; implemented by `SessionRules`
|
||
│
|
||
├── permission-session.ts `PermissionSession` class - state/lifecycle owner: owns context lifecycle, session-rule lifecycle (`reset`/`shutdown`/`reload`), skill entries, agent-name resolution, the config gateway, the Tell-Don't-Ask gate inputs, and `notify(message)` (UI warn over the owned context, no-op before activation); `implements ToolCallGateInputs`. The resolve role lives in `PermissionResolver`, the recorder role in `SessionRules`; handlers depend on the concrete class + `PermissionResolver`
|
||
├── path-normalizer.ts `PathNormalizer` class - the path-interpretation collaborator constructed once at the session edge with the injected `PathFlavor` (exposed as `readonly flavor`) and session `cwd` baked in; hands raw tokens, returns prepared values: `forPath`/`forLiteral` (build `AccessPath`s), `isAbsolute`/`resolveBase`/`joinBase` (flavor-aware `cd`-fold routing), `isWithinDirectory`/`isOutsideWorkingDirectory` (containment), `comparableValue` (lexical comparison for skill-prompt matching), `isInfrastructureRead`, and `forBashToken`/`interpretBashCdTarget`/`isBoundaryOutsideWorkingDirectory` (Git Bash/MSYS bash-token interpretation — safe devices preserved, `/c/…` drive mounts translated, other POSIX absolutes literal-only). Also owns `entryExists` (lstat), the existence probe deciding whether a bare bash token names a real filesystem entry, kept here so path interpretation has a single filesystem edge alongside canonicalization (ADR 0009). A facade over the `path/` and `access-intent/path-normalization` primitives; holds no platform discriminator — every platform question delegates to `flavor`, so no consumer reads `process.platform` or threads `cwd`
|
||
├── access-intent/ Access-intent domain: turns `(toolName, input)` into what is being accessed (bash decomposition, MCP targets, path extraction, the `AccessPath` value object and `AccessIntent` union)
|
||
│ ├── path-normalization.ts `AccessPath`'s representation backing: `normalizePathForComparison` (lexical absolute, via `flavor.comparable`), `canonicalNormalizePathForComparison` (symlink-resolved + win32-lowercased via `flavor.fold`), `normalizePathPolicyLiteral` (literal cleanup), `getPathPolicyValues` (lexical ∪ relative match set) + `PathPolicyValueOptions`; pure derivation over an injected `PathFlavor`
|
||
│ ├── access-intent.ts `AccessIntent` discriminated union each gate emits: `tool` (raw input the manager normalizes) and `access-path` (an `AccessPath` for every path gate — `path`, `external_directory`, and the per-tool path-bearing surfaces `read`/`write`/`edit`/`grep`/`find`/`ls`). Constraint: `ResolvedAccessIntent` (`tool | path-values`) is what the manager consumes after the resolver unwraps `access-path` via `matchValues()` — `path-values` is still not gate-emitted, keeping the manager string-based (the ADR 0002 boundary), but since #597 it has a second legitimate producer: the forwarded-serving wire builds a `path-values` intent directly from a `ForwardedAccessIntent`'s child-fixed `matchValues`, via `buildResolvedIntentFromMatchValues` (`input-normalizer.ts`)
|
||
│ ├── access-path.ts `AccessPath` value object: `matchValues(): string[]` (lexical alias union ∪ canonical, the match set), `boundaryValue(): string` (symlink-resolved + win32-lowercased), `value(): string` (lexical absolute display form), `resolvedAlias(): string | undefined` (the canonical form only when distinct, for disclosing a symlink target in a prompt/denial); `forPath(pathValue, { cwd, resolveBase?, flavor })` serves every path surface, `forLiteral(literal)` builds a literal-only path with no canonical for the unknown-base bash case, and `forDevice(devicePath)` preserves an MSYS device path verbatim. Type-distinct accessors make the lexical/canonical conflation a compile error
|
||
│ ├── tool-kind.ts `ToolKind` string-union + `classifyToolKind(toolName)` — the single dispatch point deciding what an invocation accesses (bash command / MCP target / skill / path-bearing tool / extension) once at the normalize boundary; imports only `PATH_BEARING_TOOLS` (AccessPath-free, so `permission-manager.ts` may consume it without breaching the ADR 0002 string boundary). Also owns `isMcpCheck({ toolName, source })`, the shared MCP-ness predicate the presentation consumers dispatch on
|
||
│ ├── input-normalizer.ts Surface-specific input normalization → NormalizedInput
|
||
│ ├── mcp-targets.ts MCP multi-name target derivation
|
||
│ ├── tool-input-path.ts `getToolInputPath` (built-in / MCP / extension path extraction) + `getPathBearingToolPath` (built-in-only)
|
||
│ ├── path-surfaces.ts Static surface/tool lookup sets: `PATH_BEARING_TOOLS`, `READ_ONLY_PATH_BEARING_TOOLS`, `PATH_SURFACES`
|
||
│ └── bash/
|
||
│ ├── parser.ts Lazy tree-sitter-bash parser: `TSNode` interface (exported), `getParser = memoizeAsyncWithRetry(initParser)` (exported); `warmBashParser()` / `getWarmBashParser(): TSParser | null` / `resetWarmBashParser()` (test-only) expose the resolved parser synchronously after a `before_agent_start` warm-up so the advisory bash path can decompose at gate parity
|
||
│ ├── node-text.ts Quote-aware AST node-text resolver: `resolveNodeText` (pure), `SKIP_SUBTREE_TYPES` (node types whose *text* is never an argument — heredoc/comment), `ARG_NODE_TYPES` (argument-value node-type set); delegates expansion nodes to `shell-variable-expansion.ts`, falling back to the node's literal text
|
||
│ ├── nested-execution.ts Shared nested-execution vocabulary for both bash surfaces: `NESTED_EXECUTION_CONTEXTS` (substitution node type → `BashCommandContext`), `EXECUTION_HOST_TYPES` (node types that are not commands or argument values but whose subtree can host a command that really runs — redirects, heredoc/herestring bodies), and `forEachNestedExecution(node, visit)`, which searches strictly within a subtree and does not descend past a context it finds. Constraint: the command surface and the path surface must share one definition of a nested execution, or a command gated on one surface escapes the other (#741)
|
||
│ ├── shell-variable-expansion.ts Pure plain-reference resolver: `resolvePlainVariableExpansion(node): string | null` — `$HOME`/`${HOME}` → `os.homedir()`, `$PWD`/`${PWD}` → `.` (the base-relative marker, so the resolver's existing `resolveBase` applies it after `cd` folding). Plainness is structural (exactly one `variable_name` child, otherwise only delimiters), so an operator form (`${HOME:-/tmp}`, `${#HOME}`) is rejected without enumerating bash's expansion operators. Constraint: the resolvable set is closed at `HOME`/`PWD` — widening it is an ADR 0009 amendment, and the expansion vocabulary lives only here, never in the classifiers
|
||
│ ├── token-collection.ts Bash argument/flag tokenizer: `collectPathCandidateTokens`, `collectCommandTokens`, `collectRedirectTokens`, `extractCommandName` (exported); private `PATTERN_FIRST_COMMANDS` table and pattern/generic collectors, plus `collectEmbeddedOptionValues` — emits the inline value of an `--opt=value` argument as its own token, read from the argument nodes (a pattern-first collector classifies a flag and never emits it), so an option-embedded path is classified by the ordinary shape rules without per-command option tables (#645). Also projects the operands of a command hosted in a redirect destination or an interpolating heredoc body; the `EXECUTION_HOST_TYPES` dispatch sits above the `SKIP_SUBTREE_TYPES` check because `heredoc_body` is in both sets and the host reading must win — its prose stays out of the path surface while its substitution's operands enter it (#741)
|
||
│ ├── command-enumeration.ts Bash command enumerator: `collectCommands` (exported) + the descend/skip tables and the node→`CommandWord` adapter; owns the `BashCommand` interface including the `wrapperKind` discriminant and the display-only `executedUnit`; strips leading `variable_assignment` prefixes from command units. Constraint: `COMMAND_ENUM_SKIP` holds only genuinely inert types (`comment`, `heredoc_end`) — a node that is not a command but can host one belongs in `EXECUTION_HOST_TYPES`, and conflating the two questions is the bypass #741 fixed
|
||
│ ├── wrapper-analysis.ts Pure word-based wrapper interpretation: `classifyWrapperWords` (the `WrapperKind` discriminant — `"opaque-payload"` for `bash -c`/`eval`, `"indirection"` for sudo/env/xargs/find -exec/…) and `executedUnitOf` (the command a wrapper actually runs), over the shared wrapper vocabulary. Constraint: both answers read one vocabulary — the shape that floors a unit to `ask` and the shape that names its inner command cannot drift. `executedUnitOf` is display-only and fails to `null` rather than to a guess, so it never weakens a gate
|
||
│ ├── bash-path-resolver.ts `BashPathResolver` class (constructed with a `PathNormalizer` and an optional `workdir`): `resolve(rootNode): ResolvedBashPaths` walks the AST once, tagging each path-candidate token with the `EffectiveBase` in force at its position, and returns `{ externalPaths: AccessPath[], ruleCandidates: BashPathRuleCandidate[] }`; routes every path through the injected `PathNormalizer`. Both projections fall back to the shared `probeBareToken` for a token the shape gates reject, admitting it only when `normalizer.entryExists` confirms it names a real entry and the effective base is known; `projectRuleCandidates` passes `this.normalizer.flavor` so a win32 backslash-relative token is recognized like its `/` form; `projectExternalPaths` decides outside-cwd from the `AccessPath`'s canonical boundary via `collectIfExternal`, treating a literal-only bash token as unconditionally external. Constraint: consults no ruleset — candidacy is a filesystem question and the decision belongs to the gates (ADR 0009). The subtlest region in the package
|
||
│ ├── msys-bash-tokens.ts Pure win32 bash-token shape classifier: `classifyWin32BashToken(token): BashTokenShape` (`device` | `drive-mount` with translated `windowsPath` | `posix-absolute` | `plain`); no filesystem, no `process.platform` read; the return type of `PathFlavor.bashTokenShape`, consumed by `PathNormalizer.forBashToken`/`interpretBashCdTarget`
|
||
│ ├── token-classification.ts Pure token classifiers: `classifyTokenAsPathCandidate` (strict: `/`, `~/`, `..`, Windows drive-letter), `classifyTokenAsRuleCandidate(token, flavor)` (broader: also dot-files, relative paths, the drive-letter backslash form, and — under the win32 flavor — a backslash-relative token), and `classifyBareTokenCandidate(token)` (prelude-only: returns any token whose shape does not rule out a path, for the resolver to probe). Constraint: policy-free — no classifier consults the ruleset (ADR 0009)
|
||
│ ├── sync-commands.ts `parseBashCommandsSync(command): BashCommand[] | null` — warm-parser-backed synchronous command enumeration; returns `null` in the pre-warm window so the advisory bash path falls back to whole-string matching
|
||
│ └── program.ts Born-ready `BashProgram` value object: `parse(command, normalizer, options?)` eagerly resolves all three slices at construction; parameter-free getters `commands()`, `externalPaths(): AccessPath[]`, `pathRuleCandidates()`. `commands()` splits the chain AND descends into command/process substitutions and subshells — wherever they appear, including a redirect destination and an interpolating heredoc body (#741) — tagging each nested command with its execution `context`, stripping any leading `variable_assignment` prefix, and flagging wrapper units with a `wrapperKind` so their decision floors to `ask`
|
||
├── handlers/ Handler classes with narrow constructor injection
|
||
│ ├── index.ts Barrel re-exports
|
||
│ ├── lifecycle.ts SessionLifecycleHandler (session: `PermissionSession` + resolver + serviceLifecycle + audit); writes the decision-audit summary on `session_shutdown`
|
||
│ ├── before-agent-start.ts AgentPrepHandler (session + resolver + toolRegistry + `warmParser: () => void`); shouldExposeTool pure helper; recomputes the active set + system-prompt override every fire; fire-and-forget `warmParser()` triggers the tree-sitter warm-up
|
||
│ ├── permission-gate-handler.ts PermissionGateHandler (session + toolRegistry + pipeline + skillInputPipeline + runner); `handleToolCall` returns the internal total `GateOutcome`; validateRequestedTool + getEventInput + extractSkillNameFromInput pure helpers
|
||
│ ├── tool-call-boundary.ts `createFailClosedToolCall(gate, reporter, audit, tracer)` - the only `pi.on("tool_call")` target and sole `GateOutcome` → SDK-shape translator; owns the `try/catch → block` (the SDK's `emitToolCall` does not catch a throwing handler), writes a `gate_error` review entry on throw with its own minted request id (the throw may come from anywhere in the pipeline, so no gate's id is available) via a helper that swallows so the block stays unconditional, and emits a `debugLog`-gated `permission.decision` trace per call
|
||
│ └── gates/ Pure descriptor factories + runner
|
||
│ ├── types.ts GateOutcome, ToolCallContext
|
||
│ ├── descriptor.ts GateDescriptor (carrying the `PromptPayload` as its single presentation fact), GateBypass, GateResult types, plus `DecisionEventFacts` (a decision event minus the `requestId` only the runner can supply — the type that routes every emit through the runner's stamping site). Constraint: `promptDetails` omits both `requestId` and `payload`, which the runner stamps, so a gate cannot supply either twice
|
||
│ ├── runner.ts GateRunner class — constructed with `ScopedPermissionResolver`, `SessionApprovalRecorder`, `AskEscalator` (the single-method ask-escalation seam), `DecisionReporter`, plus a live `isYoloEnabled` reader (read per gate; the sole place a post-resolution ask is reconciled with yolo); `run(gate, agentName)` dispatches null / bypass / descriptor and mints the request id before the branch, so a request that never prompts is identified exactly as one that does; its private `emitDecision` is the sole site stamping that id onto a `DecisionEventFacts`
|
||
│ ├── tool-call-gate-pipeline.ts `ToolCallGateInputs` interface (`getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`, `getPathNormalizer`, `getShellToolAliases`) + `ToolCallGatePipeline` class — constructed with `ScopedPermissionResolver` + `ToolCallGateInputs`; owns bash-command extraction + the single `BashProgram.parse`, `ToolPreviewFormatter` construction, the infra-dir list, the six gate producers, and the run loop; `evaluate(tcc, runner)` returns the first block outcome or allow
|
||
│ ├── skill-input-gate-pipeline.ts `SkillInputGateInputs` + `GateNotifier` interfaces + `SkillInputGatePipeline` class — owns the raw `checkPermission` pre-check, deny notify, `describeSkillInputGate` descriptor, and `runner.run`; `evaluate(skillName, agentName, notifier, runner)` makes the `input` path symmetric with the `tool_call` path
|
||
│ ├── helpers.ts deriveDecisionValue, deriveResolution, buildDecisionEvent, resolveYoloGrant (the standing yolo grant covering a resolved check — a ruleset-rewritten allow or, under yolo, a residual ask)
|
||
│ ├── skill-read.ts describeSkillReadGate - pure descriptor factory
|
||
│ ├── skill-input.ts describeSkillInputGate - pure descriptor factory; takes a pre-computed check result so the runner reuses the caller's check
|
||
│ ├── external-directory.ts describeExternalDirectoryGate - pure descriptor/bypass factory; builds an `AccessPath`, delegates policy resolution to `resolveExternalDirectoryPolicy`, uses `accessPath.boundaryValue()` for the outside-CWD boundary and infra-read checks, and discloses `accessPath.resolvedAlias()` when it names a location distinct from the typed path
|
||
│ ├── external-directory-policy.ts Shared external-directory policy check for both gates: `resolveExternalDirectoryPolicy(path, resolver, agentName)` emits an `access-path` `AccessIntent` on the `external_directory` surface; `selectUncoveredExternalPaths(paths, resolver, agentName)` resolves a set, keeps the not-allowed entries, and selects the worst via `pickMostRestrictive`
|
||
│ ├── bash-external-directory.ts describeBashExternalDirectoryGate - pure descriptor/bypass factory over the injected `BashProgram` (`externalPaths()`); delegates the per-path alias matching and worst-uncovered selection to `selectUncoveredExternalPaths`
|
||
│ ├── bash-path.ts describeBashPathGate - pure descriptor/bypass factory for bash path rules over the injected `BashProgram` (`pathRuleCandidates()`); evaluates each candidate's `AccessPath` via an `access-path` `AccessIntent` and selects the worst uncovered token via `pickMostRestrictive`, keeping the raw token for prompts/logs/approvals and `path.value()` for the approval pattern
|
||
│ ├── candidate-check.ts `pickMostRestrictive` - pure deny > ask > allow selection over PermissionCheckResults (first-wins on ties); shared by the bash gates and the external-directory policy helper
|
||
│ ├── bash-path-extractor.ts Thin facade (`extractExternalPathsFromBashCommand`) over `BashProgram`
|
||
│ ├── bash-command.ts `resolveBashCommandCheck` - pure combiner over caller-supplied `BashCommand[]` units, checks each unit on the `bash` surface, tags the winning result with the offending command's execution `context`, selects via `pickMostRestrictive`; when empty, resolves the whole command only for a trivially-empty command and otherwise returns an explicit `deny` covering it, else fails closed to a synthetic `ask` with the `<unparseable-bash-command>` sentinel
|
||
│ ├── path.ts describePathGate - pure descriptor factory for cross-cutting path rules; builds an `AccessPath` and emits an `access-path` `AccessIntent` on the `path` surface so it matches the canonical (symlink-resolved) form like `external_directory`
|
||
│ ├── tool.ts describeToolGate - pure descriptor factory for the per-tool gate; for path-bearing built-in tools the pipeline builds an `AccessPath` and emits an `access-path` intent on the tool-name surface so per-tool rules match lexical ∪ canonical, and the session-approval value derives from `accessPath.value()`; bash/MCP/extension tools keep the raw `tool` intent
|
||
│ └── index.ts Barrel re-exports
|
||
│
|
||
├── index.ts Extension factory - event wiring, collaborator construction (established injection-bag wiring kept inline per the anti-procedure-splitting rule)
|
||
├── bash-advisory-check.ts `resolveBashAdvisoryCheck(command, agentName, resolver)` — routes an advisory `bash` query through the gate's shared `resolveBashCommandCheck` over `parseBashCommandsSync` units, falling back to a whole-string `tool` intent in the pre-warm window; kept out of `access-intent/` to avoid a domain→handler import
|
||
├── permissions-service.ts `LocalPermissionsService` class - in-process implementation of `PermissionsService`; injected with narrow collaborator interfaces (a `resolve` + `getToolPermission` resolver view, a `getPathNormalizer` session view, the formatter/access-extractor/authorizer registrars); routes path-surface queries through the resolver as an `access-path` intent so external policy queries match lexical ∪ canonical like the gates, and bash queries through `resolveBashAdvisoryCheck` for decomposed fidelity
|
||
├── service-lifecycle.ts `ServiceLifecycle` interface + `PermissionServiceLifecycle` class — owns the process-global service publish (child-gated), ready emit, and session teardown ordering
|
||
├── service.ts PermissionsService interface, Symbol.for() accessor (cross-extension API); public surface published as a self-contained dist/public.d.ts bundle
|
||
├── permission-events.ts Event channel constants, payload types, emit helpers. `PermissionUiPromptEvent` carries the payload's `request` core alongside the flat `surface`/`value` display projection — the gate surface and the display surface are two facts, not one (#292)
|
||
├── permission-request-id.ts `createPermissionRequestId()` — the one mint for a permission request's `perm-<uuid>` id; distinct from the host's `toolCallId`, which stays alongside it as the join back to the Pi transcript
|
||
├── permission-ui-prompt.ts Centralized construction for `permissions:ui_prompt` event payloads - `buildUiPrompt` is the single builder for direct and forwarded asks, keeping the emitted contract shape in one place. It projects the prompt payload's `request` core onto the event and nothing else: the bus is the narrowest renderer, so no evidence reaches it (ADR 0011 §6)
|
||
├── config-store.ts `ConfigStore` class — owns `config` + `lastConfigWarning`; `ConfigReader`, `SessionConfigStore`, `CommandConfigStore` narrow interfaces
|
||
├── config-loader.ts File I/O, format detection, strict zod validation (fail-closed) for config files
|
||
├── config-schema.ts Zod schemas - single source of truth for the config shape; derives the JSON Schema (buildPermissionsJsonSchema) and the config types
|
||
├── config-paths.ts Path derivation
|
||
├── extension-paths.ts `ExtensionPaths` value object - immutable path constants derived from `agentDir` (and optional Pi `getPackageDir()`) at startup (`computeExtensionPaths`)
|
||
├── config-reporter.ts Structured log entries for resolved config
|
||
├── config-modal.ts /permission-system slash command UI
|
||
├── extension-config.ts Runtime knobs (debugLog, yoloMode, etc.)
|
||
│
|
||
├── permission-merge.ts Deep-shallow merge for flat permission configs
|
||
├── async-cache.ts `memoizeAsyncWithRetry` - memoizes an async factory but drops a rejected result so the next call retries; used by `access-intent/bash/parser.ts` for resilient tree-sitter parser init
|
||
├── safe-system-paths.ts `SAFE_SYSTEM_PATHS` (OS device files: `/dev/null`, `/dev/std{in,out,err}`) + `isSafeSystemPath`
|
||
├── path/ Path-language domain: the win32-vs-POSIX decision resolved once, plus the co-rewritten path leaves
|
||
│ ├── path-flavor.ts `PathFlavor` interface + `pathFlavorForPlatform` factory + `win32PathFlavor`/`posixPathFlavor` singletons — the platform's path *language* as one immutable collaborator (`impl`, `matchOptions`, `fold`, `comparable`, `isWithin`, `hasPathSeparator`, `bashTokenShape`). Constraint: holds the package's only `=== "win32"` comparison; injected once from `index.ts` into `PermissionManager` / `PermissionSession` (→ `PathNormalizer`) / `SubagentDetection`
|
||
│ ├── canonicalize-path.ts Best-effort symlink resolution via `realpathSync` — walks up to longest existing ancestor and re-appends non-existent tail; ENOENT/ENOTDIR safe, EACCES/ELOOP fall back to lexical form; takes an injected `PathFlavor`
|
||
│ ├── path-containment.ts Pure path geometry over already-canonical operands: `isPathOutsideWorkingDirectory` (excludes safe system paths, then defers containment to `PathFlavor.isWithin`; no derivation, no filesystem)
|
||
│ └── pi-infrastructure-read.ts `isPiInfrastructureRead` - read-only-tool auto-allow within infra dirs / project-local `.pi/{npm,git}`; takes an already-canonical path + injected `PathFlavor`
|
||
├── node-modules-discovery.ts Global node_modules resolution (walk-up + npm root -g fallback)
|
||
├── system-prompt-sanitizer.ts Narrow Available tools section + filter guidelines to the active set
|
||
├── skill-prompt-sanitizer.ts Skill prompt filtering by policy
|
||
├── permission-prompts.ts Agent-facing pre-check reasons (missing tool name, unknown tool) refused before any permission check runs
|
||
├── presentation/ Prompt presentation: the payload a gate emits, and the renders over it (ADR 0011)
|
||
│ ├── prompt-payload.ts `PromptPayload` (the `kind` discriminant, the `request` invariant core, the complete `evidence` list, the `annotations` slot) + `localRequester`/`findEvidence`/`allEvidence` + `asPromptPayload`, the all-or-nothing tolerant guard the forwarded wire's reader narrows through. Constraint: the payload is complete by contract — it never truncates and never decides what a human sees, so elision is a property of a render (ADR 0011 §2). The guard lives beside its type so a new request fact updates it next door rather than in a distant reader
|
||
│ ├── tool-ask-payload.ts `buildToolAskPayload` — the bash, MCP, and generic-tool asks; carries the invoked tool name when a shell alias re-exposes bash (#574) and the wrapper's executed unit (#713)
|
||
│ ├── path-ask-payload.ts `buildPathAskPayload`, `buildExternalDirectoryAskPayload`, `buildBashExternalDirectoryAskPayload` — each escaping path carries its canonical alias as that evidence entry's `detail`, so a bounded render cannot show a path while eliding what it resolves to
|
||
│ ├── skill-ask-payload.ts `buildSkillAskPayload`, `buildSkillPathAskPayload` — the skill is the decision-relevant value (it is what the policy names); a skill read carries the path it was reached through as evidence
|
||
│ ├── forwarded-ask-payload.ts `buildForwardedAskPayload` — a two-branch projection, not a synthesizer: the child's own payload with only `requester` re-stamped to the request's authoritative provenance, or a degraded `kind: "forwarded"` render built from the display fields a payload-less request does carry. Constraint: the serving node is the only party that knows the ask arrived over the wire, so it re-stamps the requester and passes every other child fact through untouched
|
||
│ ├── dialog-renderer.ts `renderPromptDialog(payload, budget, paint)` — the bounded render for the inline dialog and the `select`/`input` fallback: aligned one-fact-per-line layout, a per-field width cap, a row budget over the evidence, and whole-token highlighting of the flagged element. Also `RenderBudget`/`DEFAULT_RENDER_BUDGET`/`resolveRenderBudget` (the configured budget) and `completeViewBudget` (the complete view). Constraint: the row budget bounds evidence and the field cap bounds the core — a core fact is shortened, never dropped (ADR 0011 §3 over §5)
|
||
│ ├── line-fitting.ts `fitLinesToWidth` — wrap-then-truncate to a terminal width, so each line is one visual row; shared by the `ctx.ui.custom` dialog, whose contract requires it, and by the renderer, which cannot count rows before wrapping
|
||
│ ├── fact-vocabulary.ts `flaggedElements`/`flaggedElementLabel`/`valueLabel`/`describeBashCommandContext` — the render vocabulary shared by every renderer over a payload: which element an ask flags, what it is called, and how a nested execution context reads. Owned by no renderer, so the dialog, the agent text, and the review log cannot disagree about what an ask is flagging
|
||
│ ├── agent-renderer.ts `EXTENSION_TAG` + `renderPolicyDenial`/`renderUserDenial`/`renderUnavailableDenial` — the agent-facing render of a refused ask. Constraint: it identifies the call and never reproduces it (ADR 0011 §7) — the bash command is never rendered, and the flagged path/target/skill is capped
|
||
│ └── review-log-renderer.ts `renderReviewLogFacts(payload)` — the request facts the review log persists (ADR 0011 §6), and no evidence or annotations. Constraint: exposure does not grow — evidence is the unbounded part `docs/decisions/0010-permission-log-secret-exposure.md` bounds
|
||
├── tool-input-preview.ts Pure tool-input text utilities (truncation, line counting, count formatting), serialization + default constants; `serializeToolInputPreview` (prompt, unredacted) and `serializeRedactedToolInputPreview` (log) are separate entry points because the input is flattened to a string before the writer sees its keys
|
||
├── tool-input-prompt-formatters.ts Pure per-tool prompt formatters (edit/write/read) + getPromptPath helper
|
||
├── tool-preview-formatter.ts ToolPreviewFormatter class - config-dependent prompt + log formatting; seam-first dispatch consults ToolInputFormatterLookup before built-in switch
|
||
├── tool-input-formatter-registry.ts ToolInputFormatter type, ToolInputFormatterLookup + ToolInputFormatterRegistrar interfaces, ToolInputFormatterRegistry class - persistent registry for custom previews
|
||
├── tool-access-extractor-registry.ts ToolAccessExtractor type, ToolAccessExtractorLookup + ToolAccessExtractorRegistrar interfaces, ToolAccessExtractorRegistry class - persistent registry letting extensions declare a tool's filesystem path for the path/external_directory gates
|
||
├── builtin-tool-input-formatters.ts Built-in formatters registered at startup: formatMcpInputForPrompt keyed to "mcp"
|
||
├── tool-registry.ts ToolRegistry interface + tool name validation
|
||
├── active-agent.ts Agent name detection from session/system prompt
|
||
├── authority/ Subagent detection, the Authorizer spine, and forwarded-permission escalation
|
||
│ ├── authorizer.ts `Authorizer` (non-terminal chain link, `authorize(details, query, log): Promise<AuthorizerVerdict>` - handed a session-scoped `PermissionQuery` and an `AuthorizerLog` review-log seam per ADR 0007 §3) + `TerminalAuthorizer` (terminal, `authorize(details): Promise<PermissionPromptDecision>` - cannot defer, enforced type-level) + `AuthorizerVerdict` (`allow | deny | defer`) + `SelectedAuthority` (`{ terminal, adjudicatesLocally }`) + `AuthorizerSelectionDeps` + `selectAuthorizer(ctx, deps): SelectedAuthority` - the once-per-activation hasUI/isSubagent/deny dispatch, returning the chain role that dispatch implies (`adjudicatesLocally: false` only for the relaying `ParentAuthorizer` arm, ADR 0007 §7)
|
||
│ ├── authorizer-chain.ts `composeAuthorizerChain(links, terminal, query, log)` - folds non-terminal `NamedAuthorizer` links ahead of the context-selected terminal (`defer` → next link, `allow`/`deny` → decision stamped `decidedBy: {kind: "authorizer", name, verdict, reason}` at the point the loop breaks, so a link that deferred is not credited), injecting `query` and the review-log `log` into each link; zero links returns the terminal instance (identity)
|
||
│ ├── decision-source.ts `DecisionSource` discriminated union (`user | authorizer | rule | session_approval | yolo | infrastructure_read | unavailable | gate_error | forwarded`) + depth-bounded tolerant guard `asDecisionSource`. Constraint: each variant is self-contained (it repeats its own surface/pattern/origin/name/reason) because the forwarded response file carries no such columns to lean on; the recursive `forwarded` variant is read off disk, so its guard is depth-bounded and rejects an over-deep chain whole rather than truncating it
|
||
│ ├── authorizer-registry.ts `AuthorizerRegistry` (+ `AuthorizerLookup`/`AuthorizerRegistrar` ISP interfaces) - name → link `authorize` map mirroring `ToolAccessExtractorRegistry`; one instance in `index.ts`, exposed cross-extension via `PermissionsService.registerAuthorizer`; throw-on-duplicate, identity-guarded disposer
|
||
│ ├── delegation-envelope.ts `encloseInDelegationEnvelope(authorize)` + `DELEGATION_EXCLUDED_SURFACES` - the bounded-delegation checkpoint (ADR 0007 §5): caps a link's `allow` on `path`, undetermined surfaces, and `external_directory` except for built-in read-only path tools (`read`, `find`, `grep`, `ls`); deny/defer pass through
|
||
│ ├── local-user-authorizer.ts `LocalUserAuthorizer` class - `TerminalAuthorizer` for a session with UI and the single `permissions:ui_prompt` emit site: renders a forwarded ask's provenance as a non-degraded broadcast + `(Subagent)` title, then dispatches to the inline keybind dialog (TUI) or the `select`/`input` fallback
|
||
│ ├── permission-dialog.ts Dialog option semantics + `requestPermissionDecisionFromUi` (`select`/`input` fallback) + `PermissionPromptDecision` (whose `decidedBy` is required) and `UnattributedDecision` (the same minus it); the mode dispatch lives in `permission-prompt-component.ts`
|
||
│ ├── permission-prompt-decision.ts Pure decision model (`reducePrompt` + `PromptModelConfig`/`PromptViewState`) for the inline keybind dialog - hotkey arming (double-press), step transitions, reason validation; no SDK/TUI imports
|
||
│ ├── permission-prompt-component.ts Inline `ctx.ui.custom<UnattributedDecision>` keybind dialog (TUI) driven by the decision model + the `requestPermissionDecision` mode dispatcher (tui → inline, else fallback); the reason step delegates to the pi-tui `Input` line editor (rebuilt per visit, so a backed-out draft cannot be undone back into a later ask) and forwards Pi's `app.tools.expand` action in the decision/scope steps only, never during reason entry. Constraint: the dispatcher is the one place a human surface is chosen, so it is where the decision is attributed (`decidedBy: {kind: "user", via}`) - the dialog model and the fallback each naming themselves would be two sites that must agree with its branch
|
||
│ ├── bracketed-paste.ts `collapsePastedNewlines(data)` - rewrites the content between a chunk's `\x1b[200~`/`\x1b[201~` markers so each newline run becomes one space, keeping a multi-line paste readable in the single-line reason field (the line editor deletes newlines outright, joining the words across a break); markers preserved, anything that is not a complete paste chunk returned unchanged
|
||
│ ├── denying-authorizer.ts `DenyingAuthorizer` class - least-privilege `TerminalAuthorizer` for a session with no reachable authority; denies with the `confirmationUnavailable` marker so the ask path derives the `confirmation_unavailable` resolution, attributed `decidedBy: {kind: "unavailable"}`
|
||
│ ├── authorizer-selection.ts `AuthorizerSelection` class - context-owning `AskEscalator` implementation (`escalate(details)`); selects the authority once per activation, and per ask resolves the `authorizerChain` config to registered links (config order; unregistered names skipped fail-safe with an `authorizer_chain_unregistered_link` review event; consulted names recorded as `authorizer_chain_resolved`; each wrapped in the delegation envelope), composes them via `composeAuthorizerChain`, and delegates via `PermissionPrompter`; a relaying node resolves none and records `authorizer_chain_delegated` instead (one chain per node, ADR 0007 §7)
|
||
│ ├── permission-prompter.ts `PermissionPrompter` class (`PermissionPrompterApi`) - review-log bracketing (waiting → approved/denied) around `authorizer.authorize(details)`, recording the decision's `decidedBy` on the outcome entries only (the waiting entry has no decider yet); `PromptPermissionDetails` type (carries the child-fixed `accessIntent` facts a forwarded ask relays)
|
||
│ ├── subagent-detection.ts SubagentDetection class - single owner of subagent detection (SubagentDetector.isSubagent + RegisteredChildDetector.isRegisteredChild); delegates to subagent-context
|
||
│ ├── subagent-context.ts Pure subagent execution context detection (registry + env vars + filesystem)
|
||
│ ├── subagent-registry.ts SubagentSessionRegistry class + getSubagentSessionRegistry() process-global accessor - in-process subagent session tracking
|
||
│ ├── serving-registry.ts ServingSessionRegistry class + getServingSessionRegistry() process-global accessor, split into the `ServingAnnouncer` (poller) and `ServingLookup` (forwarding child) seams - which in-process sessions are draining a forwarded-permission inbox; `composeServingAnnouncers` fans one announcement across every channel a serving session publishes on
|
||
│ ├── forwarding-liveness.ts The filesystem half of the same question, for a child that shares no memory with its parent: `ServingHeartbeatStore` (a `ServingAnnouncer` publishing `<forwardingDir>/serving/<id>.json` with the served session, its pid, and its refresh time; throttled, never throws, and sweeps records of dead processes once per session) + `HeartbeatReader` classifying a target as alive/absent/stale/dead_pid + `ForwardingLivenessJudge` (`TargetServingLookup`), which routes a liveness question to the channel that can answer it by the target's `self`/`registry`/`env` provenance. Constraint: the records live beside `sessions/`, never inside it, so liveness stays disjoint from the request/response cleanup ordering (#398)
|
||
│ ├── subagent-lifecycle-events.ts subscribeSubagentLifecycle() - subscribes to @gotgenes/pi-subagents child lifecycle events; registers/unregisters child sessions in SubagentSessionRegistry (ADR 0002)
|
||
│ ├── forwarder-context.ts `ForwarderContext` read-interface + `getSessionId`/`getCwd` - shared by the escalation and serving roles
|
||
│ ├── permission-forwarding.ts Cross-session forwarding wire types (`ForwardedPermissionRequest`, which carries the child's `PromptPayload` rather than a sentence assembled under the child's config; `ForwardedPermissionResponse`, whose optional `decidedBy` names what decided inside the responding session, distinct from the `responderSessionId` that names where; the `ForwardedAccessFacts`/`ForwardedAccessIntent` intent schema per ADR 0008) + `resolvePermissionForwardingTarget`, which returns the resolved session id together with its `self`/`registry`/`env` provenance (the routing key for which liveness channel may judge the target) + `encodeSessionIdForPath`, shared by both session-keyed layouts under the forwarding root
|
||
│ ├── approval-escalator.ts `ParentAuthorizer` class - `TerminalAuthorizer` for a subagent session: escalates the ask up the tree via the request-write/poll machinery, completing the child-fixed facts into a `ForwardedAccessIntent` (stamps `requesterCwd`/`principal`), `ctx` bound at construction; adopts the requester's `requestId` as the forwarded request's `id` (falling back to a fresh mint when it could not safely name a file — at a relay hop that id came off disk); every abandonment path (unresolvable target, unusable directories, unwritable request, unserved target, unreadable response, timeout) denies with `confirmationUnavailable` plus a path-naming `denialReason` — reused verbatim as the `unavailable` decider's reason so the two cannot drift — and discards the request so a late answer cannot arrive; an answered request's decision is nested under a `forwarded` decider carrying the responder's own
|
||
│ ├── forwarded-request-server.ts `ForwardedRequestServer` class (`InboxProcessor`) - serving-down role: `processInbox()` drains forwarded requests and resolves each like a local action - `ServingPolicy` (recorded authority) then `AskEscalator` on `ask`; `ServingPolicy.resolve(intent: ForwardedAccessIntent)` is intent-shaped (agent-scoped to `principal.agentName`, child-fixed `matchValues` used as-is, never re-derived through this session's `PathNormalizer`/cwd), floors to `ask` when `accessIntent` is absent (version skew); projects the request's access facts onto the escalated ask (`surface`/`matchValues`/`boundaryValue` only — `requesterCwd`/`principal` stay off the ask details, and the bounded-delegation checkpoint's exclusion reads the projected gate surface, #635); writes its decider onto the response (its own matched rule in full, the escalated decision's source, or a `gate_error` when the escalation itself threw), and the grant-scope translation rewrites the scope but never the decider; one-hop canary
|
||
│ ├── forwarding-io.ts Forwarding filesystem helpers - request/response read-write (tolerant read of the optional `accessIntent` and `decidedBy` fields; an unusable decider is dropped without rejecting the decision it accompanies), location derivation, atomic JSON writes (owner-only; `rename` preserves the temp file's mode). Constraint: the readers rebuild an allowlist of known fields, so a wire field added without being listed here is silently dropped
|
||
│ └── forwarding-manager.ts `ForwardingController` interface + `ForwardingManager` class - drives the forwarded-permission inbox polling lifecycle; tells `ForwardedRequestServer.processInbox`, and publishes the polled session id to the `ServingAnnouncer` plus a `forwarded_permission.serving_started`/`serving_stopped` review entry. Constraint: the per-tick re-announcement runs ahead of the processing guard, so a session whose human is deliberating at a forwarded dialog keeps announcing while `processInbox` is held open
|
||
├── session-logger.ts `SessionLogger` interface + `PermissionSessionLogger` class; owns JSONL-writer composition, IO-failure warning dedup, and notify sink
|
||
├── logging.ts JSONL review/debug log writer; serializes through `redactedJsonStringify` and creates both logs owner-only. Constraint: `writeLine` is the only place a line is produced, so both the key-name mask and the review stream's width bound live there and no write path can escape either
|
||
├── log-field-cap.ts `capLogFieldWidths` + `resolveReviewLogFieldWidth` + `DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH` - the review log's `reviewLogFieldMaxWidth` bound. Constraint: narrows by length alone and never reads a value to decide what to shorten, which is what keeps it a cap rather than redaction
|
||
├── json-safe-stringify.ts `createJsonSafeReplacer` (Error → plain object, bigint → string, cycles → `[Circular]`) + `safeJsonStringify`; separate from the writer because the prompt path serializes tool input too, and only the log path redacts
|
||
├── log-redaction.ts `isSensitiveLogKey` + `redactedJsonStringify` - key-name masking applied at the log-write boundary. Constraint: structural, never value-shape; see `docs/decisions/0010-permission-log-secret-exposure.md`
|
||
├── log-file-permissions.ts Owner-only mode constants + best-effort `restrictExistingPathToOwner`; shared by the log writer, the logs-dir helper, and forwarding IO
|
||
├── status.ts Footer status bar integration
|
||
├── value-guards.ts Runtime type guards (`toRecord`, `getNonEmptyString`)
|
||
├── yaml-frontmatter.ts Minimal YAML/frontmatter parsing (`parseSimpleYamlMap`, `extractFrontmatter`)
|
||
└── types.ts Core type definitions; the config-shape types (PermissionState, FlatPermissionConfig, etc.) are re-exported from config-schema.ts; domain type guards `isPermissionState`, `isDenyWithReason`
|
||
```
|
||
|
||
## Improvement roadmap — Phase 13: The prompt-presentation seam
|
||
|
||
### Findings (planned 2026-08-15)
|
||
|
||
The declared candidate is [ADR 0011](../decisions/0011-prompt-presentation-contract.md) (the prompt-presentation contract), whose Staging section assigns its decomposition to this planning pass.
|
||
The cause is a structural fusion of presentation with decision-making, recorded in the [Prompt presentation](#prompt-presentation) section above: six sites assemble a flat prompt `message` string (`formatAskPrompt`'s three branches, the skill prompts, the external-directory prompts, `formatPathAskPrompt`, the per-tool previews, and the parent-side forwarded prefix) that travels unchanged to every consumer — the inline dialog, the `select`/`input` fallback, the review log, the `permissions:ui_prompt` broadcast, and the forwarded wire.
|
||
Because the payload is a pre-rendered sentence, elision is a payload property rather than a render property: the bash branch has no cap, nothing bounds height ([#710]), a forwarded ask is assembled twice under two configs, and every denial path echoes unbounded input into the agent's context.
|
||
The phase implements the contract's staged first step — the complete payload and the renderer seam — so [#710] is fixed by construction and [#713] becomes a conformance requirement of the payload's invariant core rather than a separate enhancement.
|
||
|
||
Corroboration (fallow + sweeps, 2026-08-15): health 88 (A; deductions are unit size and coupling), dead code 0, duplication 0.2% (the documented intentional `literalTextOf`/`resolveNodeText` pair plus one new 16-line internal clone in `token-collection.ts`).
|
||
The repeated-discriminator sweep found no new family — survivors are validation-edge `typeof` guards, per-node AST dispatch, and boundary translation, idiomatic per the taxonomy.
|
||
The `value-guards.ts` refactoring target remains rejected (healthy high-fan-in leaf).
|
||
The craftsmanship scout re-refuted all three fallow giant-test flags (nested `describe` trees of small behavior-named tests, unchanged since Phase 12) and found one concentrated cluster: six duplicated local test factories (`PermissionCheckResult` builders and `ToolPreviewFormatter` options literals) across `denial-messages.test.ts`, `permission-prompts.test.ts`, and `tool-preview-formatter.test.ts` — exactly the presentation test files the spine rewrites, so the extraction rides Step 1 as a tidy-first prep commit.
|
||
Directory check: the spine rewrites the ~8 cohesive presentation modules at the flat `src/` root, so per the recorded reorg convention this phase seeds `src/presentation/` and the touched modules reach their final home the first time.
|
||
|
||
Open-issue sweep dispositions (user-decided):
|
||
|
||
- [#710] — adopted as Step 2 (the bounded local renderers are its fix by construction); closed with it.
|
||
- PR [#738] (highlight the flagged element in TUI prompts) — swept in during Step 1 planning, having been filed the day before this phase was scoped.
|
||
Highlighting is a **render** concern under ADR 0011, so its intent is adopted in Step 2's dialog renderer with authorship credited, and the PR closes as superseded rather than being rebased — the same disposition [#716] received.
|
||
Both were adopted and credited when Step 2 landed.
|
||
- [#713] — its inner-command fact enters the payload in Step 1 and becomes visible in every render in Step 2 (the `runs` line); it closed with Step 2.
|
||
- [#721] / [#735] — adopted as Step 5: out-of-process forwarding liveness; [#735]'s scenario 1 (dead parent) is resolved by it, while scenario 2 (a parent whose turn is occupied) stays with the [#722] diagnosis, which remains open and out of scope.
|
||
- [#726] — adopted as Step 6 (decision provenance).
|
||
- [#732] — adopted as Step 7 (model-judge `agentDir` fix).
|
||
- [#655] — adopted as Step 8 (`deriveApprovalPattern` flavor injection).
|
||
- [#620] — deferred with recorded rationale: one phase old, non-gating, the `registerAuthorizer` seam it consumes exists, and the phase's capacity goes to the presentation spine; [#698] and [#706] express user demand for the same capability and fold into it when it is scheduled.
|
||
- [#519] — kept open with recorded rationale (not a silent re-defer): still externally blocked on Pi SDK `UIContext` evolution; it closes or schedules when the SDK ships the capability.
|
||
- [#639] — deferred to a later phase: first sweep since filing, and its policy-model design budget does not fit alongside the presentation spine.
|
||
- [#742] — swept out of scope this phase by composition decision (first explicit sweep); it is the last member of the #306/#741 nested-command bypass family and is a strong candidate for the next phase's spine or an independent step.
|
||
- [#610] — adopted mid-phase as Step 10, after a [#745] planning question surfaced the gap underneath it.
|
||
It was originally swept out as a feature issue; the sweep read the symptom (a parent-side consumer with no terminal signal) without reading the cause.
|
||
Tracing it found three id conventions and no id at all on any non-prompting path, so the local foundation split out as Step 9 ([#752]) and this issue narrowed to the cross-session half it actually reported.
|
||
- [#752] — filed mid-phase as Step 9, the foundation Step 10 needs.
|
||
- Feature issues [#736], [#720], [#691], [#688], [#687], [#686], [#680], [#658], [#609], [#604], [#603], [#699] — out of scope for a structural phase; [#654] and [#648] become downstream packages over the annotator and evidence-formatter seams per ADR 0011 §8, which are themselves deferred until the payload exists.
|
||
|
||
Trajectory: Phase 12's maximum step priority was 20; this phase's is 20 (Step 1).
|
||
No decline, so the regular rotation continues.
|
||
|
||
### Health metrics
|
||
|
||
| Metric | Baseline (2026-08-15) | Phase 13 target |
|
||
| ----------------------------------------------------------------------- | --------------------- | --------------- |
|
||
| Flat-assembler sites (`formatAskPrompt` references in `src/`) | 4 | 0 ✅ |
|
||
| Forwarded-wire `message: string` field (`permission-forwarding.ts`) | 1 | 0 ✅ |
|
||
| Broadcast `message: string` field (`permission-ui-prompt.ts`) | 1 | 0 ✅ |
|
||
| `src/presentation/` domain directory present | 0 | 1 ✅ |
|
||
| Legacy `message` render sites (`renderLegacyMessage` in `src/`) | 17 | 0 ✅ |
|
||
| Forwarding-liveness module present (`authority/forwarding-liveness.ts`) | 0 | 1 ✅ |
|
||
| `decidedBy` provenance sites in `src/` | 0 | ≥ 1 ✅ |
|
||
| Request-id mint sites in `src/` | 2 | 1 ✅ |
|
||
| `requestId` fields in `permission-events.ts` (ui\_prompt + decision) | 1 | 2 ✅ |
|
||
| Model-judge resolves `agentDir` via `getAgentDir` (`config-loader.ts`) | 0 | ≥ 1 |
|
||
| Ambient `node:path` import in `session-rules.ts` | 1 | 0 |
|
||
| fallow health score | 88 (A) | ≥ 88 |
|
||
| Production duplication | 0.2% | ≤ 0.2% |
|
||
| Dead exports | 0 | 0 |
|
||
|
||
Recompute commands (run from the repo root):
|
||
|
||
- Flat-assembler sites: `grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Wire message field: `grep -c "message: string" packages/pi-permission-system/src/authority/permission-forwarding.ts`
|
||
- Broadcast message field: `grep -c "message: string" packages/pi-permission-system/src/permission-ui-prompt.ts`
|
||
- Presentation directory: `ls packages/pi-permission-system/src | grep -c presentation`
|
||
- Legacy message sites: `grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Liveness module: `ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness"`
|
||
- Provenance sites: `grep -rn "decidedBy" packages/pi-permission-system/src | wc -l`
|
||
- Id mint sites: `grep -rnE "Math\.random\(\)\.toString\(36\)|randomUUID\(\)" packages/pi-permission-system/src --include="*.ts" | wc -l`
|
||
- Event request ids: `grep -c "requestId" packages/pi-permission-system/src/permission-events.ts`
|
||
- Model-judge agentDir: `grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.ts`
|
||
- Ambient path import: `grep -c "node:path" packages/pi-permission-system/src/session-rules.ts`
|
||
- Health/duplication/dead exports: `pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system` / `pnpm fallow dead-code --workspace @gotgenes/pi-permission-system`
|
||
|
||
The presentation-directory, liveness-module, `decidedBy`, and `getAgentDir` rows grep for names the phase has not created yet; the step that creates each (Steps 1, 5, 6, 7 respectively) must either use the roadmap's name or update the metric row in the same commit.
|
||
The two request-id rows were added mid-phase with Steps 9 and 10, and the legacy-message row with Step 4, so their baselines are measured at that point rather than at the phase-open snapshot.
|
||
|
||
### Steps
|
||
|
||
#### ✅ Step 1: `PromptPayload` and its builders — the assembly sites become one payload ([#744])
|
||
|
||
**Cause:** presentation is fused with decision-making — each gate renders its facts into a sentence at the point of decision, so no consumer downstream can render under its own budget; the flat `message` string is the fusion made concrete.
|
||
|
||
- **Smell:** Category C (coupling/boundary flaw — the payload/render boundary does not exist).
|
||
- **Target:** new `src/presentation/prompt-payload.ts` (the `PromptPayload` type per ADR 0011 §2 — `request` invariant core, `evidence`, `annotations` slot — plus builders); `permission-prompts.ts`, `handlers/gates/external-directory-messages.ts`, and the skill-prompt formatting migrate into `src/presentation/` as payload builders; the gate descriptors emit the payload alongside the facts they already compute; `PromptPermissionDetails` carries it; `message` is derived *from* the payload during the transition (lift-and-shift, no consumer changes yet).
|
||
The payload's `request.executedUnit` carries the inner command of an unstrippable wrapper — [#713]'s fact, entering here.
|
||
Tidy-first prep commit: extract the scout's duplicated fixtures (`makePermissionCheckResult`, a shared `ToolPreviewFormatter` factory) into `test/helpers/` and migrate the three presentation test files.
|
||
- **Outcome:** every ask has a complete structured payload; `grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l` goes 4 → 0; `ls packages/pi-permission-system/src | grep -c presentation` goes 0 → 1; behavior is unchanged (the derived `message` is byte-compatible or near-compatible, pinned by existing tests).
|
||
- **Landed:** both metrics hit their targets, and `message` is byte-identical — every former assembler's string assertion now runs against `renderLegacyMessage`, which reads the payload alone, so the suite is the proof the payload is complete.
|
||
`PromptPermissionDetails.payload` is **required**, making "every ask carries a complete payload" a compile-time guarantee rather than a convention.
|
||
Planning found a **sixth** assembler the issue and ADR 0011 both omit — `formatPathAskPrompt`, with two consumers — and found that [#713]'s fact had no source at all: `classifyWrapperCommand` only flagged a wrapper, so the new `wrapper-analysis.ts` resolves what one actually runs.
|
||
Three departures from ADR 0011 §2's illustrative type are documented at their declarations: a `kind` discriminant, `| null` over `| undefined` (the payload goes on the JSON wire in Step 3), and `commandContext` as a request fact.
|
||
- **Impact 5 / Risk 2 / Priority 20.**
|
||
|
||
Release: batch "presentation-payload"
|
||
|
||
#### ✅ Step 2: Bounded local renderers — the dialog and fallback render the payload under a budget ([#710])
|
||
|
||
**Cause:** same cause, consumed at the human's decision surface — with no renderer layer, the dialog shows whatever the assembler produced, so a subagent's oversized tool input takes over the parent's viewport and the operator decides blind or scrolls away the transcript.
|
||
|
||
- **Smell:** Category C, with the user-visible symptom filed as the [#710] bug.
|
||
- **Target:** new `src/presentation/dialog-renderer.ts` rendering the payload for the inline TUI dialog and the `select`/`input` fallback under a row budget plus a per-field width cap (ADR 0011 §5), with marked elision and a reachable complete view (§4); [#716]'s aligned one-fact-per-line rendering intent adopted here; the invariant core (§3) — including `executedUnit` — always visible, which closes [#713]; the row-budget config field follows the established `config-schema.ts` → `extension-config.ts` → `mergeUnifiedConfigs()` path (the #332/#347 drop class) with `pnpm run gen:schema`.
|
||
- **Outcome:** a forwarded ask with pathological input renders within the budget with the complete view reachable; [#710] and [#713] close; the local prompt path no longer reads `details.message`.
|
||
- **Landed:** the reported ask — a 200-line here-string, measured at 202 rows locally and 205 forwarded, identically at widths 80/120/160 — renders inside the 24-row default with its request facts intact.
|
||
Planning settled the reading that makes that possible: §3's "never elided" means never *omitted*, so the field cap applies to the core and §5's own here-string rationale is coherent with it.
|
||
The row budget therefore bounds evidence and the field cap bounds the core, with an entry admitted whole or dropped.
|
||
`Ctrl+O` gained the dialog's own expansion alongside its host forward ([#642]) rather than a second binding, and the hint names it only when the render dropped something.
|
||
PR [#738]'s highlight target is derived from the payload rather than carried as a `PromptPermissionDetails` field, so it cannot drift from the rendered text.
|
||
- **Impact 5 / Risk 3 / Priority 15.**
|
||
|
||
Release: batch "presentation-payload"
|
||
|
||
#### ✅ Step 3: The cross-boundary swap — payload replaces `message` on the wire and the broadcast ([#745])
|
||
|
||
**Cause:** same cause at the two cross-boundary contracts — the forwarded wire relays the child's prose (assembled under the child's config) and the broadcast ships the full sentence to any unconsented observer, so consistency across local and forwarded asks is structurally unattainable and the bus over-discloses.
|
||
|
||
- **Smell:** Category C (boundary flaw), with the ADR 0011 §6 broadcast narrowing as the disclosure fix.
|
||
- **Target:** `src/authority/permission-forwarding.ts` (the request carries the payload, `message` removed), `src/authority/approval-escalator.ts` (child serializes it), `src/authority/forwarded-request-server.ts` (serving renders the child's facts under the parent's budget; a version-skewed request without a payload renders from whatever fields it carries, never empty — ADR 0011 §9), `src/permission-ui-prompt.ts` (broadcast narrowed to the `request` facts; forwarded provenance retained in full), soft-deprecation of `toolInputPreviewMaxLength`/`toolTextSummaryMaxLength` via the config-issue channel (§5).
|
||
Breaking: `feat!:` with a migration note naming the payload fields that supersede `message` on both contracts.
|
||
- **Outcome:** `grep -c "message: string"` goes 1 → 0 in both `permission-forwarding.ts` and `permission-ui-prompt.ts`; a forwarded ask renders identically in kind to a local one; the bus discloses request facts and verdicts only.
|
||
- **Impact 4 / Risk 3 / Priority 12.**
|
||
- **Landed:** both metric rows are `0`; `asPromptPayload` (an all-or-nothing tolerant guard beside its type) admits the payload through the wire's `asX` reader, and the required-core gate no longer demands `message`, so an older child's request is served from its display fields rather than rejected.
|
||
`docs/migration/0745-prompt-payload-contracts.md` names the superseding fields and the upgrade-the-parent-first ordering.
|
||
The [#710] row-budget invariant was re-measured at the new shape — the reported here-string ask arriving as `kind: "bash"` with the child's real evidence — and stays inside the 24-row default.
|
||
|
||
Release: batch "presentation-contract"
|
||
|
||
#### ✅ Step 4: The agent-facing and review-log renderers ([#746])
|
||
|
||
**Cause:** the same unbounded payload that took over the viewport is echoed verbatim into the agent's context on every denial (the human's constraint is rows; the agent's is tokens), and the review log persists prompt wording as a side effect of assembly rather than as a configured render.
|
||
|
||
- **Smell:** Category C, plus the log-growth concern of `docs/decisions/0010-permission-log-secret-exposure.md`.
|
||
- **Target:** `denial-messages.ts` migrates to `src/presentation/agent-renderer.ts` under ADR 0011 §7 — the agent renderer identifies the call (surface, matched pattern, verdict, the human's typed reason) and never reproduces its input; the review-log write path (`permission-prompter.ts` / `session-logger.ts`) renders the payload under its existing configured limits instead of persisting `message`.
|
||
- **Outcome:** denial text is structurally bounded (no raw-command interpolation on any denial path); the review log's growth is a configured decision; key-name redaction unchanged.
|
||
- **Impact 3 / Risk 2 / Priority 12.**
|
||
- **Landed:** `grep -rn "renderLegacyMessage" src` goes 17 → 0 — the review log was the last `message` reader, so `legacy-message.ts` went with it and `PromptPermissionDetails.message` is gone.
|
||
Planning settled the reading §7 leaves open: *identifying* a call includes naming which of its operands the rule fired on, while *reproducing* it means echoing the command or the tool-input body, which no render does.
|
||
The flagged element is therefore rendered under a field cap rather than structurally excluded — the departure is deliberate, because correlation is already structural (Pi returns a block reason as that call's own tool result, stamped with its `toolCallId`, with the call's arguments retained in context) and what the agent cannot recover is sub-call granularity.
|
||
`DenialContext` dissolved into `PromptPayload`: every field it uniquely held is one §7 forbids rendering, and the operator's deny-with-reason text passes from the resolved check as an argument, which also makes it reach the agent on every surface rather than tool and bash alone.
|
||
Measured on a live 7.07 MB review log: dropping `message` (21.5%) and capping every field at the new `reviewLogFieldMaxWidth` (a further 7.1%, all of it `command`) removes 28.7%, shortening 4.3% of command entries.
|
||
The bound went to `writeLine` rather than each renderer, and the log facts to `GateRunner` rather than each of the seven gates, on the same reasoning: a producer cannot forget what it never supplies.
|
||
|
||
Release: batch "presentation-contract"
|
||
|
||
#### ✅ Step 5: Out-of-process forwarding liveness ([#721], fixes [#735] scenario 1)
|
||
|
||
**Cause:** the forwarding timeout conflates "a human is deliberating" with "nobody is home" — for an out-of-process child (which shares no `globalThis` with its parent) the 10-minute `PERMISSION_FORWARDING_TIMEOUT_MS` is the only signal, so every ask forwarded to a dead parent burns the full timeout and reports a denial the user never made.
|
||
The in-process serving registry (#719) already made the two distinguishable for in-process children; the filesystem channel lacks the equivalent.
|
||
|
||
- **Smell:** Category C (lifecycle/boundary flaw at the cross-process edge).
|
||
- **Target:** new `src/authority/forwarding-liveness.ts` (a filesystem liveness signal — [#721] names two candidate mechanisms, claim artifact or serving heartbeat; `/plan-issue` picks on ergonomics), `src/authority/forwarding-manager.ts` (serving node maintains the signal), `src/authority/approval-escalator.ts` (child fast-fails on absent/stale liveness after a short grace, with a path-naming `denialReason` and `confirmationUnavailable`, matching the in-process judgement's safe direction).
|
||
- **Outcome:** a child forwarding to a target no live session is draining abandons in seconds instead of 600, resolving [#735] scenario 1; scenario 2 stays with [#722]; `ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness"` goes 0 → 1.
|
||
- **Impact 4 / Risk 3 / Priority 12.**
|
||
- **Landed:** the metric is 1, and the mechanism choice went to the heartbeat on a constraint the issue's framing did not carry: `processInbox` drains serially, awaiting each escalation, so a per-request claim would leave a second request unclaimed for as long as a human deliberates on the first — and claiming the whole batch up front degrades the artifact to "the loop saw you" while adding a third file to the tree whose removal ordering produced [#398].
|
||
The records therefore live beside `sessions/`, never inside it, which is what keeps that ordering untouched.
|
||
The load-bearing detail is where the re-announcement sits: `ForwardingManager`'s tick refreshes ahead of its processing guard, because a parent holding `processInbox` open for a deliberating human is serving throughout, and refreshing behind the guard would let its record decay exactly when it is most demonstrably alive.
|
||
The two-channel dispatch went into `ForwardingLivenessJudge` rather than `ParentAuthorizer`, so the poll loop asks one question about a target and the in-process and out-of-process rules cannot drift.
|
||
Absence of a record counts as unserved (user decision at the clarification gate), which is what resolves the reported case — a cleanly exited parent leaves nothing behind — at the cost of an upgrade-ordering requirement now documented in `docs/subagent-integration.md`.
|
||
This deliberately reverses [#719]'s rule that an `env`-resolved target is never fast-failed; a dead pid is judged immediately, while a merely stale record still waits out the staleness window, and no new config field was added.
|
||
|
||
Release: independent
|
||
|
||
#### ✅ Step 6: Decision provenance — `decidedBy` on permission decisions ([#726])
|
||
|
||
**Cause:** the decision path knows what decided (human prompt, session approval, config rule, authorizer link, auto-allow, timeout) and discards it before the log write, so an audit cannot distinguish a human approval from an auto-approval — the decision-provenance principle: record what decided and on what basis, not only the outcome.
|
||
|
||
- **Smell:** Category C (a fact established at the decision point dies before its consumer).
|
||
- **Target:** a `decidedBy` discriminated union threaded from the decision sites (`GateRunner`'s fast paths, `PermissionPrompter`, the `Authorizer` chain, `ForwardedRequestServer`) into the review-log entries and the forwarded response; lands after Step 4 so the provenance fields ride the new log renderer rather than the retiring `message` shape.
|
||
- **Outcome:** every terminal `permission_request.*` / `forwarded_permission.*` entry names its decider with enough detail to reconstruct the decision; `grep -rn "decidedBy" packages/pi-permission-system/src | wc -l` goes 0 → ≥ 1.
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
- **Landed:** the metric is 37, and the scope narrowed by user decision during planning: `PermissionDecisionEvent` is **not** touched, because the bus channel's consumers are not yet known and it is the narrowest renderer under ADR 0011 §6.
|
||
Two of the issue's three asks were already answered — [#752] made the request id shared across the forwarding hop, and there is no `/permissions` history view to surface into.
|
||
Each variant is self-contained (it repeats its own surface, pattern, origin, link name, or reason) rather than leaning on a sibling log column: that duplicates `surface` and the pattern locally, and it is the only shape that survives onto the response file, which has no such columns.
|
||
The `forwarded` variant is recursive, so the requesting side records *which session* answered and *what within it* decided as two facts — flattening would make a remote decision read as local.
|
||
Its guard is depth-bounded because the value is read off disk.
|
||
Attribution is stamped at the site that decides, never derived: the mode dispatcher names the human's surface (the dialog model and the fallback return an `UnattributedDecision`, the shape `GateBypass.decision` already used for the request id), the chain names the link at the point its loop breaks, and both absent-authority paths reuse the string they already report to the model so the two cannot drift.
|
||
`decidedBy` is required on `PermissionPromptDecision` and `GateBypass`, making completeness a compile-time guarantee rather than a convention.
|
||
Measured on a live 7.44 MB review log: 1432 terminal prompted decisions carried no decider, and the record adds 95–134 bytes to 5777 decision-bearing lines — a 7.4% worst case, against the 28.7% [#746] removed.
|
||
|
||
Release: independent
|
||
|
||
#### Step 7: Model-judge honors `PI_CODING_AGENT_DIR` ([#732])
|
||
|
||
**Cause:** `pi-permission-model-judge` recomputes the global config scope from a hardcoded `~/.pi/agent` instead of the SDK's `getAgentDir()`, so the two packages disagree about where the global scope lives whenever `PI_CODING_AGENT_DIR` is set — and the configured judge silently never registers, indistinguishable in the review log from "not installed".
|
||
|
||
- **Smell:** Category F (cross-package divergence on a single source of truth).
|
||
- **Target:** `packages/pi-permission-model-judge/src/config-loader.ts` resolves `agentDir` via `getAgentDir()` from `@earendil-works/pi-coding-agent`, as pi-permission-system does.
|
||
- **Outcome:** both packages read the global scope from the same directory; `grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.ts` goes 0 → ≥ 1; ships as a `fix:` in the model-judge component.
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
|
||
Release: independent
|
||
|
||
#### Step 8: `deriveApprovalPattern` takes the injected `PathFlavor` ([#655])
|
||
|
||
**Cause:** `deriveApprovalPattern` (`session-rules.ts`) reads `node:path`'s ambient `dirname`/`sep`, bypassing the injected `PathFlavor` that owns every other platform decision — the one surviving violation of the #562/#510 invariant, producing mixed-separator patterns on a real Windows host and untestable win32 behavior on POSIX CI.
|
||
|
||
- **Smell:** Category C (ambient platform read; decide-once violation).
|
||
- **Target:** `src/session-rules.ts` — derive the pattern through the flavor's `impl`/separator, threading the flavor from the call sites that already hold a `PathNormalizer`.
|
||
- **Outcome:** `grep -c "node:path" packages/pi-permission-system/src/session-rules.ts` goes 1 → 0; a win32 unit test can pin the derived pattern; `refactor:` (hidden type — cuts no release on its own).
|
||
- **Impact 2 / Risk 1 / Priority 10.**
|
||
|
||
Release: independent
|
||
|
||
#### ✅ Step 9: A minted request id, carried on every decision ([#752])
|
||
|
||
**Cause:** there is no permission request id — there are three conventions, and none covers a request that never prompts.
|
||
The tool-call gates borrow the SDK's `toolCallId`, the skill-input gate mints its own, and the escalation edge mints a third that discards the one it was handed; the id attaches inside `promptForApproval`, so session-approved, yolo, infrastructure-auto-allowed and policy-blocked resolutions carry no id at all, and `PermissionDecisionEvent` carries none ever.
|
||
|
||
- **Smell:** Category C (a fact established at request creation dies before its consumers), the same shape as Step 6.
|
||
- **Target:** a single mint at the top of `GateRunner.run` shared by the bypass and descriptor branches; the id carried on the non-prompting review-log writes and added to `PermissionDecisionEvent`; `GateBypass.decision` becomes an `Omit<PermissionDecisionEvent, "requestId">` so a gate keeps emitting only what it knows; `createSkillInputRequestId` deleted.
|
||
`toolCallId` keeps flowing untouched — it is the join back to the Pi transcript, and a distinct fact from the request id.
|
||
- **Outcome:** every permission request is correlatable from creation regardless of how it resolves; the mint-site count goes 2 → 1; `grep -c "requestId" packages/pi-permission-system/src/permission-events.ts` goes 1 → 2.
|
||
Additive for consumers, so it needs no major bump of its own.
|
||
- **Landed:** both metrics hit their targets, and three of the issue's own shape claims did not survive the code.
|
||
The non-prompting writes are **four**, not three — `policy_denied` is written by `applyPermissionGate` from the log context the runner hands it, so injecting the id there covers it.
|
||
Exactly **one** `GateBypass` carries a `decision` (the infrastructure-read bypass), not three; the other two carry only a `log`.
|
||
And `run`'s third parameter is **deleted**, not narrowed: `requestId: toolCallId` was its only reader.
|
||
The forwarding edge stopped minting its third id and adopts the one it is handed, so a forwarded ask carries one id from the child's gate to the human's decision — which supersedes Step 3's planned `requesterRequestId` wire field.
|
||
Two decisions beyond the issue: `randomUUID` over the package's `<ts>-<rand>-<pid>` convention (Node has no UUIDv7 — `randomUUID({ version: 7 })` silently returns a v4), and a filename-safety guard on the adopted id, since adoption is what first lets an inbound id name an outbound file.
|
||
The gate-error boundary's missing `permissions:decision` was found here and filed as [#753].
|
||
- **Impact 3 / Risk 1 / Priority 15.**
|
||
|
||
Release: independent
|
||
|
||
#### Step 10: Cross-session prompt/decision correlation ([#610])
|
||
|
||
**Cause:** a forwarded ask's prompt is emitted by the parent and its terminal decision by the child, on a different event bus for an out-of-process child — so a parent-side consumer that marks an agent blocked on `permissions:ui_prompt` has no public signal to clear it and can stay blocked forever.
|
||
Measured on the review log: 53 of 57 `forwarded_permission.request_created` entries carry an id appearing on no `permission_request.*` entry, the child's ask and the request the parent serves joined by nothing but a one-millisecond timestamp gap.
|
||
|
||
- **Smell:** Category C (boundary flaw — a lifecycle observable on one side of the forwarding edge and not the other).
|
||
- **Target:** `src/authority/forwarded-request-server.ts` emits a parent-side `permissions:decision` after the serving session's human decision, reusing the request id its own `permissions:ui_prompt` carried; `ForwardedPermissionRequest.id` **is** the child's originating `requestId` (Step 9), so it already joins the two sides' log entries.
|
||
Silent policy resolutions stay silent — no prompt, no terminal event, unchanged.
|
||
- **Outcome:** a direct prompt and its decision share one id; a forwarded prompt and its parent-side decision share one id on one bus; concurrent equivalent prompts stay independently correlatable.
|
||
- **Impact 3 / Risk 2 / Priority 12.**
|
||
|
||
Release: independent
|
||
|
||
### Step dependency diagram
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
S1["✅ Step 1 (#744): PromptPayload + builders"] --> S2["✅ Step 2 (#710): bounded local renderers"]
|
||
S2 --> S3["✅ Step 3 (#745): cross-boundary swap (feat!)"]
|
||
S2 --> S4["✅ Step 4 (#746): agent + review-log renderers"]
|
||
S4 --> S6["✅ Step 6: decidedBy provenance (#726)"]
|
||
S9["✅ Step 9 (#752): minted request id"] --> S3
|
||
S9 --> S10["Step 10 (#610): cross-session correlation"]
|
||
S3 --> S10
|
||
S4 --> S10
|
||
S5["✅ Step 5: forwarding liveness (#721)"]
|
||
S7["Step 7: model-judge agentDir (#732)"]
|
||
S8["Step 8: deriveApprovalPattern flavor (#655)"]
|
||
```
|
||
|
||
### Parallel tracks
|
||
|
||
- **Track A — prompt-presentation spine:** Steps 1 → 2 → {3, 4}.
|
||
- **Track B — forwarding liveness:** Step 5 (touches `authority/` forwarding files only; disjoint from Track A apart from `approval-escalator.ts`, which Track A's Step 3 also edits — land Step 5 before or after Step 3, not concurrently).
|
||
- **Track C — decision provenance:** Step 6, after Step 4.
|
||
- **Track D — independent fixes:** Steps 7 and 8, any time.
|
||
- **Track E — request identity:** Step 9, then Step 10 after Step 4.
|
||
Step 9 was disjoint from Track A apart from `permission-events.ts`, which Step 3 also edits (different interfaces in the same file) — it landed **before** Step 3, and its id adoption retired the `requesterRequestId` wire field Step 3 had planned, so Step 10 finds both halves in place.
|
||
Step 10 and Step 6 both enrich the review-log write path; land them in sequence.
|
||
|
||
The step numbers are discovery order, not execution order: Steps 9 and 10 were added mid-phase, and Step 9 runs before Step 3.
|
||
The diagram above is the authority on sequencing.
|
||
|
||
### Release batches
|
||
|
||
- **Batch "presentation-payload":** Steps 1, 2 (ship together; tail = Step 2; release vehicle = Step 2's `fix:` for [#710] — Step 1 is a hidden `refactor:`).
|
||
- **Batch "presentation-contract":** Steps 3, 4 (ship together; tail = Step 4; release vehicle = Step 3's `feat!:` breaking release with the `message`-replacement migration note).
|
||
- Independently releasable: Step 5 (`fix:`), Step 6 (`feat:`), Step 7 (`fix:`, model-judge component), Step 8 (`refactor:` — hidden type, batches into the next release), Step 9 (`feat:`), Step 10 (`feat:`).
|
||
|
||
## Refactoring history
|
||
|
||
The architecture above is the product of twelve completed improvement phases.
|
||
Each phase's findings, numbered plan, dependency diagram, and health metrics are preserved in a per-phase history file under [`history/`](history/).
|
||
|
||
| Phase | Theme | History |
|
||
| ----- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||
| 1 | Preview formatter extension seam | [phase-1-preview-formatter-seam.md](history/phase-1-preview-formatter-seam.md) |
|
||
| 2 | Complexity and duplication paydown | [phase-2-complexity-duplication.md](history/phase-2-complexity-duplication.md) |
|
||
| 3 | State-owning collaborators | [phase-3-collaborator-encapsulation.md](history/phase-3-collaborator-encapsulation.md) |
|
||
| 4 | Constructibility and god-object decomposition | [phase-4-constructibility.md](history/phase-4-constructibility.md) |
|
||
| 5 | Tell-Don't-Ask and decoupling sweep | [phase-5-tell-dont-ask-sweep.md](history/phase-5-tell-dont-ask-sweep.md) |
|
||
| 6 | Access-intent extraction | [phase-6-access-intent-extraction.md](history/phase-6-access-intent-extraction.md) |
|
||
| 7 | AccessPath as the universal path representation | [phase-7-accesspath-universal-representation.md](history/phase-7-accesspath-universal-representation.md) |
|
||
| 8 | Tidy first for the authority spine | [phase-8-tidy-first-authority-spine.md](history/phase-8-tidy-first-authority-spine.md) |
|
||
| 9 | The Authorizer spine | [phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md) |
|
||
| 10 | Decide-once dispatch and bash-surface hardening | [phase-10-decide-once-dispatch-bash-surface-hardening.md](history/phase-10-decide-once-dispatch-bash-surface-hardening.md) |
|
||
| 11 | Shell-tool aliasing and elicitation UX | [phase-11-shell-tool-aliasing-elicitation-ux.md](history/phase-11-shell-tool-aliasing-elicitation-ux.md) |
|
||
| 12 | Cross-session access intent and the Authorizer chain | [phase-12-cross-session-intent-authorizer-chain.md](history/phase-12-cross-session-intent-authorizer-chain.md) |
|
||
|
||
[#261]: https://github.com/gotgenes/pi-packages/issues/261
|
||
[#267]: https://github.com/gotgenes/pi-packages/issues/267
|
||
[#296]: https://github.com/gotgenes/pi-packages/issues/296
|
||
[#298]: https://github.com/gotgenes/pi-packages/issues/298
|
||
[#302]: https://github.com/gotgenes/pi-packages/issues/302
|
||
[#620]: https://github.com/gotgenes/pi-packages/issues/620
|
||
[#393]: https://github.com/gotgenes/pi-packages/issues/393
|
||
[#418]: https://github.com/gotgenes/pi-packages/issues/418
|
||
[#529]: https://github.com/gotgenes/pi-packages/issues/529
|
||
[#530]: https://github.com/gotgenes/pi-packages/issues/530
|
||
[#531]: https://github.com/gotgenes/pi-packages/issues/531
|
||
[#476]: https://github.com/gotgenes/pi-packages/issues/476
|
||
[#478]: https://github.com/gotgenes/pi-packages/issues/478
|
||
[#502]: https://github.com/gotgenes/pi-packages/issues/502
|
||
[#509]: https://github.com/gotgenes/pi-packages/issues/509
|
||
[#555]: https://github.com/gotgenes/pi-packages/issues/555
|
||
[#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
|
||
[#738]: https://github.com/gotgenes/pi-packages/pull/738
|
||
[#398]: https://github.com/gotgenes/pi-packages/issues/398
|
||
[#719]: https://github.com/gotgenes/pi-packages/issues/719
|
||
[#721]: https://github.com/gotgenes/pi-packages/issues/721
|
||
[#722]: https://github.com/gotgenes/pi-packages/issues/722
|
||
[#726]: https://github.com/gotgenes/pi-packages/issues/726
|
||
[#732]: https://github.com/gotgenes/pi-packages/issues/732
|
||
[#735]: https://github.com/gotgenes/pi-packages/issues/735
|
||
[#736]: https://github.com/gotgenes/pi-packages/issues/736
|
||
[#742]: https://github.com/gotgenes/pi-packages/issues/742
|
||
[#744]: https://github.com/gotgenes/pi-packages/issues/744
|
||
[#745]: https://github.com/gotgenes/pi-packages/issues/745
|
||
[#746]: https://github.com/gotgenes/pi-packages/issues/746
|
||
[#645]: https://github.com/gotgenes/pi-packages/issues/645
|
||
[#642]: https://github.com/gotgenes/pi-packages/issues/642
|
||
[#655]: https://github.com/gotgenes/pi-packages/issues/655
|
||
[#658]: https://github.com/gotgenes/pi-packages/issues/658
|
||
[#680]: https://github.com/gotgenes/pi-packages/issues/680
|
||
[#686]: https://github.com/gotgenes/pi-packages/issues/686
|
||
[#687]: https://github.com/gotgenes/pi-packages/issues/687
|
||
[#688]: https://github.com/gotgenes/pi-packages/issues/688
|
||
[#691]: https://github.com/gotgenes/pi-packages/issues/691
|
||
[#698]: https://github.com/gotgenes/pi-packages/issues/698
|
||
[#699]: https://github.com/gotgenes/pi-packages/issues/699
|
||
[#706]: https://github.com/gotgenes/pi-packages/issues/706
|
||
[#720]: https://github.com/gotgenes/pi-packages/issues/720
|
||
[#639]: https://github.com/gotgenes/pi-packages/issues/639
|
||
[#648]: https://github.com/gotgenes/pi-packages/issues/648
|
||
[#654]: https://github.com/gotgenes/pi-packages/issues/654
|
||
[#603]: https://github.com/gotgenes/pi-packages/issues/603
|
||
[#604]: https://github.com/gotgenes/pi-packages/issues/604
|
||
[#609]: https://github.com/gotgenes/pi-packages/issues/609
|
||
[#610]: https://github.com/gotgenes/pi-packages/issues/610
|
||
[#519]: https://github.com/gotgenes/pi-packages/issues/519
|
||
[#752]: https://github.com/gotgenes/pi-packages/issues/752
|
||
[#753]: https://github.com/gotgenes/pi-packages/issues/753
|
||
[ADR-0002]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md
|