mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
@@ -50,7 +50,7 @@ type RuleOrigin =
|
||||
| "fail-closed";
|
||||
|
||||
interface Rule {
|
||||
/** The permission surface: "bash", "edit", "mcp", "skill", "external_directory", "path", etc. */
|
||||
/** The permission surface: "bash", "edit", "mcp", "skill", "path", etc. */
|
||||
surface: string;
|
||||
/** The match pattern: a command glob, tool name, file path, skill name, or "*". */
|
||||
pattern: string;
|
||||
@@ -76,158 +76,53 @@ Every config entry, default policy, session approval, and agent override normali
|
||||
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.
|
||||
A `Ruleset` keeps declaration order for deterministic tie-breaking, but declaration order is not the primary precedence rule.
|
||||
For all matching rules on the requested surface, `evaluate()` chooses the most specific pattern:
|
||||
|
||||
1. exact match;
|
||||
2. more literal characters;
|
||||
3. fewer `*` wildcards;
|
||||
4. longer pattern;
|
||||
5. later declaration only for a complete tie.
|
||||
|
||||
The synthesized universal `*` is therefore a fallback. A specific deny cannot be shadowed by a later broad allow.
|
||||
|
||||
### 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" };
|
||||
}
|
||||
```
|
||||
`evaluate()` filters to matching surface/value candidates, ranks them with the specificity comparator, and returns the highest-ranked rule. If none match, it returns the built-in `ask` fallback.
|
||||
|
||||
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.
|
||||
## Decision composition
|
||||
|
||||
## Composed ruleset
|
||||
PermissionManager does not load an ordinary project policy. It composes these authorities:
|
||||
|
||||
All rule sources are concatenated into a single flat array.
|
||||
Index position determines priority (higher index wins):
|
||||
1. global-only Hard Guards (first and final for matching `deny`);
|
||||
2. session approvals;
|
||||
3. active agent restrictions from global/project agent Markdown;
|
||||
4. the single global ordinary policy;
|
||||
5. built-in `ask`.
|
||||
|
||||
```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];
|
||||
}
|
||||
```
|
||||
Rules inside one authority use pattern specificity. Results from independent gates (`path`, per-tool, Bash) compose most-restrictive-wins. AutoReview is not a ruleset layer: it only receives a completed ordinary-policy `ask`.
|
||||
|
||||
## 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
|
||||
HG["Global hard-guards.jsonc"] --> Intent["Resolved access intent"]
|
||||
GP["Global policy.jsonc"] --> PM["PermissionManager"]
|
||||
AF["Optional active-agent frontmatter"] --> PM
|
||||
SR["SessionRules"] --> PM
|
||||
Intent --> Pipeline["skill -> path -> bash -> tool gates"]
|
||||
Pipeline --> PM
|
||||
PM -->|allow| Allow["execute"]
|
||||
PM -->|deny| Deny["block"]
|
||||
PM -->|ask| Chain["authorizerChain"]
|
||||
Chain -->|allow/deny| Result["resolve"]
|
||||
Chain -->|defer or exhausted| Human["human prompt"]
|
||||
```
|
||||
|
||||
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).
|
||||
Project-level `policy.jsonc` is intentionally absent. Project agent Markdown may restrict one active agent, but it is not a second ordinary configuration scope.
|
||||
|
||||
Tool Search alone owns `getActiveTools()` / `setActiveTools()`. Permission System does not participate in tool visibility; it receives only calls Pi actually invokes.
|
||||
## Config format
|
||||
|
||||
```jsonc
|
||||
@@ -239,7 +134,6 @@ The package does not discover or enumerate agents — it reads one sub-document
|
||||
"mcp": { "*": "ask", "exa:*": "allow" },
|
||||
"skill": { "*": "ask", "librarian": "allow" },
|
||||
"path": { "*": "allow", "*.env": "deny" },
|
||||
"external_directory": "ask"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -278,19 +172,11 @@ Input normalization for all surfaces lives in `src/access-intent/input-normalize
|
||||
|
||||
### 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.
|
||||
Per-tool path patterns are emitted as `path-values` intents. Relative inputs may carry lexical, cwd-normalized absolute, and canonical aliases so one configured pattern can protect the same file across equivalent spellings.
|
||||
|
||||
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.
|
||||
There is no outside-CWD classifier and no `external_directory` intent. Paths outside the working directory use the same `path`, per-tool, and Bash policy evaluation as paths inside it.
|
||||
|
||||
Tools such as SSH that carry remote paths register an explicit extractor so local path normalization is not inferred from `input.path`.
|
||||
## Session approvals: the cache-miss model
|
||||
|
||||
Session rules are stored as `Ruleset` and are generalized to all surfaces.
|
||||
@@ -335,7 +221,6 @@ The pattern determines what class of future requests auto-approve:
|
||||
| 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:
|
||||
|
||||
@@ -485,8 +370,8 @@ 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.
|
||||
- `registerToolAccessExtractor(toolName, extractor)` - declare the local filesystem path a non-conventional tool accesses so the cross-cutting `path` gate sees it, or explicitly suppress local-path inference for remote tools; returns a disposer.
|
||||
- `registerAuthorizer(name, authorize)` - register a named live-authority chain link (`allow | deny | defer`); it decides nothing until the operator names it in `authorizerChain`, then `allow` / `deny` is final for any ordinary-policy `ask`, including `path`; 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).
|
||||
|
||||
@@ -560,7 +445,7 @@ The courier hop carries facts, not judgment — but what the serving node recons
|
||||
|
||||
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).
|
||||
The same child-fixed facts let the serving node's configured authorizer decide a forwarded ask without re-deriving its path or trust context.
|
||||
|
||||
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.
|
||||
|
||||
@@ -585,7 +470,7 @@ 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).
|
||||
A future "disable everything" mode — overriding denies too — would require a separate, deliberately named bypass. Appending a broad allow cannot override a more-specific deny under specificity-based evaluation.
|
||||
It is not built, and it would be requested by name, never conflated with yolo.
|
||||
|
||||
### Fail-closed on an invalid non-global scope
|
||||
@@ -624,15 +509,13 @@ ordinaryManager.check(intent, session rules)
|
||||
ask -> authorizer chain -> human terminal fallback
|
||||
```
|
||||
|
||||
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Project config, per-agent config, session grants, yolo mode, and model authority cannot loosen a hard deny.
|
||||
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
|
||||
|
||||
Completed AutoReview verdicts flow to an owner-only count ledger. The reviewer callback strips rationale/transcript/raw payload and persists bounded fingerprints. Reaching the configured checkpoint marks analysis pending. At `agent_settled`, a tool-free, extension-free, in-memory AgentSession receives only aggregated records. Its strict JSON proposal is validated against the evidence, persisted, and injected into the same session as a follow-up. The checkpoint advances only after a valid proposal is stored; shutdown leaves pending work durable. No analysis path mutates either policy file.
|
||||
|
||||
The public authorizer registry remains available for other named authorities. Configuration order still determines chain order, unregistered names are skipped fail-safe, and the delegation envelope remains the final boundary on model `allow` decisions.
|
||||
The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
|
||||
|
||||
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.
|
||||
Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
|
||||
|
||||
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.
|
||||
@@ -643,44 +526,11 @@ The two compose cleanly because a promoted token emits the same structured descr
|
||||
|
||||
### 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)).
|
||||
The deterministic core accepts a closed `ResolvedAccessIntent` union emitted by the gate pipeline. One resolver answers those intents; adding a gate does not add a side-channel policy method.
|
||||
|
||||
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.
|
||||
The current classifier is deterministic. Bash parsing and registered tool extractors may emit `path-values`; there is no outside-CWD intent. Model judgment is downstream and receives only completed `ask` decisions through the authorizer chain.
|
||||
|
||||
This preserves the core property that the same policy and resolved intent produce the same deterministic decision.
|
||||
### 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.
|
||||
@@ -725,160 +575,25 @@ The concept and the code role take two grammatical forms of one root, each for w
|
||||
|
||||
```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`
|
||||
├── index.ts Composition root and Pi event registration
|
||||
├── config-loader.ts Global ordinary policy loading
|
||||
├── policy-loader.ts Global policy + active-agent frontmatter gateway
|
||||
├── hard-guard-policy-loader.ts Independent global-only hard-guard loading
|
||||
├── rule.ts Pattern matching and specificity ranking
|
||||
├── permission-manager.ts Unified intent evaluation
|
||||
├── access-intent/ Resolved intent types and resolver
|
||||
├── handlers/
|
||||
│ ├── before-agent-start.ts Session warmup and skill metadata only; no active-tool control
|
||||
│ └── gates/ skill -> path -> bash -> tool pipelines
|
||||
├── authority/
|
||||
│ ├── authorizer-selection.ts Configured authorizer resolution
|
||||
│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
|
||||
├── auto-review/ Integrated reviewer and count-based analysis
|
||||
├── service.ts Cross-extension permission service
|
||||
└── types.ts Shared domain types
|
||||
```
|
||||
|
||||
Tool visibility is deliberately absent from this tree's responsibilities. It belongs to Tool Search.
|
||||
## Improvement roadmap — Phase 13: The prompt-presentation seam
|
||||
|
||||
### Findings (planned 2026-08-15)
|
||||
|
||||
Reference in New Issue
Block a user