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)
|
||||
|
||||
@@ -2,105 +2,68 @@
|
||||
|
||||
## Config File Locations
|
||||
|
||||
Pi Permission System has an ordinary JSONC policy and an independent global hard-guard JSONC policy:
|
||||
Pi Permission System reads two user-level JSONC files:
|
||||
|
||||
| Purpose | Scope | Path |
|
||||
| ------- | ----- | ---- |
|
||||
| Ordinary policy + runtime | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Ordinary policy override | Project | `<cwd>/.pi/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Ordinary policy + runtime | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
|
||||
| Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` |
|
||||
|
||||
All paths respect `PI_CODING_AGENT_DIR`. The bundle creates missing global files once with owner-only permissions. It does not overwrite an existing file. If `policy.jsonc` is absent but the old global `config.json` exists, the bundle copies the old content into `policy.jsonc` and leaves the source file intact.
|
||||
`PI_CODING_AGENT_DIR` replaces `~/.pi/agent` when set. Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file.
|
||||
|
||||
Project policy overrides global ordinary policy and per-agent YAML frontmatter overrides both. Project policy and project agent definitions are loaded only when Pi reports that project as trusted.
|
||||
Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview.
|
||||
|
||||
Hard guards are deliberately outside this merge. They are global-only and evaluated first, without session rules. A matching hard `deny` is final and cannot be loosened by project policy, per-agent policy, a session grant, or AutoReview. A hard-file `allow` can only form a narrower exception to another pattern in that same hard-guard file. `ask` is invalid in the hard-guard file.
|
||||
Tool Search is the only extension that controls Pi's active tool list. Permission System never hides, activates, or replaces tools; it only evaluates a tool when Pi calls it.
|
||||
|
||||
> `~/.pi/agent` is the default. When `PI_CODING_AGENT_DIR` is set, Pi uses that directory instead.
|
||||
## Decision and Pattern Precedence
|
||||
|
||||
## Merge Precedence
|
||||
For ordinary policy, the decision flow is:
|
||||
|
||||
**Precedence order (later wins):**
|
||||
1. matching Hard Guard `deny`;
|
||||
2. session approval, when one exists;
|
||||
3. per-agent restriction, when an agent is active;
|
||||
4. the global ordinary policy;
|
||||
5. built-in fallback `ask`.
|
||||
|
||||
1. Global config file
|
||||
2. Project config file
|
||||
3. Global agent frontmatter
|
||||
4. Project agent frontmatter
|
||||
Within one pattern map, the most specific matching pattern wins even if `"*"` appears later. Specificity is compared in this order:
|
||||
|
||||
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
||||
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
|
||||
1. exact match before wildcard match;
|
||||
2. more literal characters;
|
||||
3. fewer `*` wildcards;
|
||||
4. longer pattern;
|
||||
5. later declaration only when all earlier measures tie.
|
||||
|
||||
**Invalid higher-precedence scope fails closed.**
|
||||
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
||||
Instead the effective policy is floored so nothing resolves more permissively than `ask`: every `allow` (including one inherited from a lower scope) is clamped to `ask`, while `deny` and `ask` are unchanged.
|
||||
So a global `bash: allow` cannot remain effective behind a project scope that was meant to deny bash but contains a typo — bash prompts until the invalid config is fixed.
|
||||
A validation warning plus a distinct fail-closed notice are emitted, and a fix + reload restores the intended policy.
|
||||
An invalid **global** scope does not trigger the clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
|
||||
This clamp is deny-preserving and, like `yoloMode`, applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`, since yolo is an explicit full-permissive opt-in.
|
||||
This makes a broad `"*": "allow"` a fallback rather than an ordering trick. For example, `"**/.env": "deny"` still wins for `.env` paths.
|
||||
|
||||
There is no separate `external_directory` decision. A path outside the current working directory is evaluated by the same configured tool, `path`, and Bash rules as any other path.
|
||||
|
||||
## Full Example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json",
|
||||
|
||||
// Runtime knobs
|
||||
"debugLog": false,
|
||||
"permissionReviewLog": true,
|
||||
"yoloMode": false,
|
||||
"doublePressToConfirm": true,
|
||||
"forwardingTimeoutMs": 600000,
|
||||
"piInfrastructureReadPaths": [],
|
||||
|
||||
// Non-bash tools that carry shell semantics
|
||||
"shellTools": {
|
||||
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
||||
},
|
||||
|
||||
// Unmatched ordinary asks are reviewed before the terminal human prompt.
|
||||
"$schema": "./schemas/permissions.schema.json",
|
||||
"authorizerChain": ["auto-review"],
|
||||
"autoReview": {
|
||||
"enabled": true,
|
||||
"provider": "openai-codex",
|
||||
"model": "codex-auto-review",
|
||||
"reasoning": "low",
|
||||
"timeoutMs": 90000,
|
||||
"includeBaselinePolicy": true
|
||||
},
|
||||
"analysis": {
|
||||
"enabled": true,
|
||||
"threshold": 500,
|
||||
"retainAnalyzedBatches": 2,
|
||||
"timeoutMs": 90000
|
||||
},
|
||||
|
||||
// Flat permission policy
|
||||
"permission": {
|
||||
"*": "ask", // universal fallback
|
||||
"*": "allow",
|
||||
"path": {
|
||||
"*": "allow",
|
||||
"*.env": "deny",
|
||||
"*.env.*": "deny",
|
||||
"*.env.example": "allow"
|
||||
"**/.env": "deny",
|
||||
"**/.env.*": "deny",
|
||||
"~/.ssh/**": "deny",
|
||||
"*": "allow"
|
||||
},
|
||||
"read": "allow",
|
||||
"write": "deny",
|
||||
"edit": "deny",
|
||||
"bash": {
|
||||
"git *": "ask",
|
||||
"git status": "allow",
|
||||
"npm *": { "action": "deny", "reason": "Use pnpm instead" }
|
||||
},
|
||||
"mcp": { "mcp_status": "allow" },
|
||||
"skill": { "*": "ask" },
|
||||
"external_directory": "ask"
|
||||
"git status*": "allow",
|
||||
"git diff*": "allow",
|
||||
"*": "ask"
|
||||
}
|
||||
},
|
||||
"autoReview": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Trailing commas are **not** supported.
|
||||
> If parsing fails, the extension falls back to `ask` for all categories.
|
||||
|
||||
> **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`.
|
||||
## Built-in AutoReview and Count-Based Analysis
|
||||
|
||||
`autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
|
||||
@@ -127,7 +90,6 @@ A successful proposal advances the checkpoint, is retained according to `retainA
|
||||
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
|
||||
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
||||
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
||||
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
|
||||
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
||||
|
||||
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
||||
@@ -171,23 +133,6 @@ Non-TUI contexts render the same facts under the same budget, without the colour
|
||||
|
||||
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
|
||||
|
||||
### `piInfrastructureReadPaths` patterns
|
||||
|
||||
Each entry is either a plain directory prefix or a wildcard pattern.
|
||||
Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
|
||||
Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
|
||||
`*` and `**` are equivalent — both cross directory boundaries.
|
||||
|
||||
Example — allow reads from a Homebrew-managed Pi install at any version:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"piInfrastructureReadPaths": [
|
||||
"/opt/homebrew/**/@earendil-works/pi-coding-agent/**"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `shellTools` — gating aliased shell tools
|
||||
|
||||
The native `bash` tool goes through the full bash enforcement stack: command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules.
|
||||
@@ -213,11 +158,11 @@ Each key is a tool name; its value maps the tool's input arguments (the keys of
|
||||
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
|
||||
| `decisionFloor` | no | `"ask"` raises Bash `allow` to `ask`; existing `ask`/`deny` are preserved. |
|
||||
|
||||
Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash, path, or external-directory policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
|
||||
Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash or path policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
|
||||
|
||||
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
|
||||
When `workdirArgument` is set, the tool's working directory is the base used to resolve relative command paths. It is evaluated through configured Bash and `path` rules; there is no separate outside-directory decision.
|
||||
|
||||
Merge semantics: `shellTools` merges by tool name and then by descriptor field across global → project. Project values can replace argument names, but omitting a globally configured `decisionFloor: "ask"` does not remove it. Because no weaker floor value is valid, a project cannot downgrade that global review boundary.
|
||||
`shellTools` comes from the single global policy. A configured `decisionFloor: "ask"` cannot be weakened by another policy scope.
|
||||
|
||||
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
|
||||
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
|
||||
@@ -248,27 +193,9 @@ Three invariants govern the chain:
|
||||
3. **Registration alone grants no authority.**
|
||||
Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
|
||||
|
||||
The chain owner caps every link with a **bounded-delegation checkpoint**. The `path` surface is always excluded. On `external_directory`, my-pi permits an `allow` for the built-in read-only path tools (`read`, `find`, `grep`, `ls`); write, edit, bash, extension/unknown tools, and missing tool identity are downgraded to `defer`.
|
||||
Deny and defer are never capped.
|
||||
The gate surface remains authoritative: a `write` blocked by a `path` rule is capped even though the displayed tool name is `write`.
|
||||
This holds for an ask forwarded up from a subagent exactly as it does for a local one.
|
||||
See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
|
||||
A named authorizer may return a final `allow` or `deny` for any request that ordinary policy resolved to `ask`, including `path`. There is no additional surface-based delegation cap. `defer` advances to the next configured authorizer and ultimately the human terminal.
|
||||
|
||||
When a **subagent** raises the ask, the chain runs one hop up.
|
||||
The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
|
||||
The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
|
||||
|
||||
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
|
||||
|
||||
| Record | Meaning |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
||||
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
||||
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
|
||||
|
||||
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
||||
Registration returns a disposer, and only one link may hold a given name.
|
||||
For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
|
||||
Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -311,13 +238,12 @@ A string value is a catch-all for that surface.
|
||||
}
|
||||
```
|
||||
|
||||
Unknown or absent tools are not required in the config.
|
||||
If a tool is not registered at runtime, this extension blocks it before permission checks run.
|
||||
Unknown or absent tools are not required in the config. Pi and Tool Search own tool registration and activation; Permission System does not alter the active tool list.
|
||||
|
||||
#### Path Patterns for File Tools
|
||||
|
||||
For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
|
||||
Patterns are matched against `input.path` using the same last-match-wins wildcard semantics as bash command patterns.
|
||||
Patterns are matched against `input.path`; the most specific matching pattern wins, using the precedence described at the top of this document.
|
||||
When Pi's current working directory is known, a relative path input is matched with both its original relative form and its cwd-normalized absolute form, so an absolute allowlist rule and a legacy relative rule can both apply to the same file.
|
||||
Per-tool path patterns also match the canonical (symlink-resolved) form, at parity with the `path` surface, so a per-tool deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
|
||||
`*` matches zero or more of any character **including** path separators — `src/*` matches both `src/foo.ts` and `src/deep/nested/foo.ts`.
|
||||
@@ -548,27 +474,18 @@ A path embedded in a long option (e.g. `--file=/tmp/patterns` in `grep --file=/t
|
||||
On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
|
||||
On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
|
||||
|
||||
Four orthogonal layers compose with most-restrictive-wins:
|
||||
Three orthogonal layers compose with most-restrictive-wins:
|
||||
|
||||
| Layer | Question | Applies to |
|
||||
| ----------------------- | --------------------------------------- | ---------------- |
|
||||
| `path` | Is this specific path pattern allowed? | All tools + bash |
|
||||
| `external_directory` | Is accessing outside CWD ok? | All tools + bash |
|
||||
| Per-tool patterns | Is this path ok for this specific tool? | Individual tools |
|
||||
| `bash` command patterns | Is this command ok? | Bash only |
|
||||
| Bash command rules | Is this command allowed? | Bash only |
|
||||
|
||||
**Which surface for "allow this directory"?**
|
||||
Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `external_directory` to **allow** a directory outside the working tree (a cache, a sibling project).
|
||||
Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
|
||||
Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
|
||||
A `path` allow cannot loosen a per-tool or Bash `ask`/`deny`; the most restrictive result remains effective.
|
||||
Configs without a `path` key use the universal fallback (`permission["*"]`) for the `path` surface.
|
||||
|
||||
Configs without a `path` key behave identically to before — the gate does not fire.
|
||||
When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
|
||||
|
||||
> **Ordering matters.**
|
||||
> Rules use last-match-wins.
|
||||
> `{ "*.env": "deny", "*": "allow" }` allows `.env` because `"*"` is last and matches everything.
|
||||
> Put the catch-all first: `{ "*": "allow", "*.env": "deny" }`.
|
||||
Pattern declaration order is not a broad override mechanism. `{ "*.env": "deny", "*": "allow" }` still denies `.env` because `*.env` is more specific.
|
||||
|
||||
#### `.env` recipe
|
||||
|
||||
@@ -606,117 +523,6 @@ Conversely, a per-tool deny still blocks even when the `path` surface allows:
|
||||
|
||||
Here `read` calls pass the `path` gate but are blocked by the `read` tool gate.
|
||||
|
||||
### `external_directory` Surface
|
||||
|
||||
Controls access to paths outside the active working directory.
|
||||
Use a pattern map to allow specific directories without opening all external access:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/development/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`external_directory` is evaluated before the normal tool permission check.
|
||||
For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
|
||||
Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
|
||||
|
||||
#### Allow an outside-CWD cache directory
|
||||
|
||||
When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/.cargo/registry/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The trailing `*` is required and it crosses subdirectory boundaries: `*` is a greedy match (not a single path segment), so `~/.cargo/registry/*` allows every file beneath the directory, however deep.
|
||||
Do not write `~/.cargo/registry/**` — `**` is not a distinct globstar, and a single `*` already recurses.
|
||||
A bare `~/.cargo/registry` (no `*`) matches only the directory entry itself, not the files inside it, which is the usual reason a hand-written allow rule appears to do nothing.
|
||||
The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs and approval dialogs.
|
||||
|
||||
For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
|
||||
Use `external_directory` when the allowance must apply to every tool.
|
||||
|
||||
Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
|
||||
Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
|
||||
|
||||
What the bash projection resolves:
|
||||
|
||||
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
|
||||
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
|
||||
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
|
||||
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
|
||||
|
||||
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
|
||||
A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
|
||||
Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
|
||||
The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
|
||||
|
||||
(The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
|
||||
|
||||
#### Symlinked paths
|
||||
|
||||
A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** and the OS-resolved (symlink-followed) path.
|
||||
This matters on macOS, where `/tmp` is a symlink to `/private/tmp`: a rule keyed on `/tmp/*` allows access via `/tmp` even though the access resolves to `/private/tmp`, and a rule keyed on `/private/tmp/*` works too.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"/tmp/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The same dual-form matching protects the `path` surface and the per-tool file patterns: a `path` (or `read`/`write`/`edit`/`grep`/`find`/`ls`) deny on `~/.ssh/*` or `*.env` also catches a symlink whose resolved target matches the pattern, so a sensitive file cannot be reached through an aliasing symlink.
|
||||
For `external_directory`, the decision of whether a path is outside the working directory always uses the resolved form, so the gate still fires for every outside-CWD access; only which allow/deny/ask pattern matches considers both forms.
|
||||
|
||||
#### Pi Infrastructure Read Auto-Allow
|
||||
|
||||
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
|
||||
Infrastructure directories include:
|
||||
|
||||
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
|
||||
2. Git-cloned global packages (`<agentDir>/git/`)
|
||||
3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
|
||||
4. Pi's own install directory (auto-discovered via the coding-agent `getPackageDir()` API, so Pi's bundled docs and examples are readable regardless of install layout)
|
||||
5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
||||
6. Any paths listed in `piInfrastructureReadPaths`
|
||||
|
||||
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
||||
|
||||
On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
|
||||
The separator folding applies to the rule pattern **and** to the value it is matched against, so either side may be written with either separator.
|
||||
A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value, and a forward-slash rule such as `"/dev/null"` matches a value that is also spelled with forward slashes.
|
||||
POSIX matching remains case-sensitive and does not fold separators.
|
||||
|
||||
#### Git Bash / MSYS paths on Windows
|
||||
|
||||
On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
|
||||
The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
|
||||
|
||||
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices rather than filesystem paths, so they never trigger the `external_directory` gate — the same exclusion that holds on POSIX.
|
||||
The cross-cutting `path` surface still governs them on both platforms: if a `path` rule matches the token, it decides.
|
||||
A device is therefore allow-listed the way any other path is, written as typed — `path: { "/dev/null": "allow" }`.
|
||||
- MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
|
||||
- Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
|
||||
|
||||
To allow-list such a path, write the rule using the path as typed — for example `external_directory: { "/tmp/*": "allow" }` — and the Windows separator folding above makes the forward-slash rule match the Git Bash token.
|
||||
|
||||
### Home Directory Expansion in Patterns
|
||||
|
||||
Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
|
||||
@@ -726,15 +532,15 @@ A prefix is recognized only when it stands alone or precedes a separator, so a l
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"*": "ask",
|
||||
"~/development/*": "allow"
|
||||
"path": {
|
||||
"~/development/*": "allow",
|
||||
"*": "ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
||||
The pattern is stored and displayed as written (for example `~/development/*`) in logs and approval dialogs.
|
||||
|
||||
Path **values** supplied by tool calls and bash commands are expanded the same way.
|
||||
This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
|
||||
@@ -918,7 +724,7 @@ It allows a curated set of commands whose only effect is to read or report — n
|
||||
Four existing behaviors keep this allowlist safe — you do not have to enumerate the destructive commands to block them:
|
||||
|
||||
1. **Redirects are gated by the `path` surface, not `bash`.**
|
||||
Allowing `cat *` allows the `cat` command, not a redirect it carries: `cat secret > out.txt` writes `out.txt` through the `path`/`external_directory` gate.
|
||||
Allowing `cat *` allows the `cat` command, not a redirect it carries: `cat secret > out.txt` writes `out.txt` through the `path` gate.
|
||||
That is why this recipe ships with `write` and `edit` denied and a `path` deny block for sensitive files.
|
||||
Keep the `path` surface locked down for anything you would not want an allowed read command to overwrite via `>`.
|
||||
2. **`find`/`fd` with an exec flag are floored to `ask`.**
|
||||
|
||||
@@ -70,9 +70,9 @@ interface PermissionsService {
|
||||
|
||||
/**
|
||||
* Register a custom access-intent extractor for a specific tool name.
|
||||
* Declares the filesystem path a tool accesses so the `path` and
|
||||
* `external_directory` gates can see it. Returns a disposer; throws if an
|
||||
* extractor is already registered for that tool name.
|
||||
* Declares the local filesystem path a tool accesses so the `path` gate can
|
||||
* see it, or suppresses local-path inference for a remote tool. Returns a
|
||||
* disposer; throws if an extractor is already registered for that tool name.
|
||||
*/
|
||||
registerToolAccessExtractor(
|
||||
toolName: string,
|
||||
@@ -85,13 +85,13 @@ interface PermissionsService {
|
||||
|
||||
| Parameter | Required | Description |
|
||||
| ----------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `surface` | Yes | Permission surface: `"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc. |
|
||||
| `surface` | Yes | Permission surface: `"bash"`, `"read"`, `"mcp"`, `"skill"`, `"path"`, etc. |
|
||||
| `value` | No | Value to evaluate (command, name, path); defaults to `""` |
|
||||
| `agentName` | No | Agent name for per-agent policy resolution |
|
||||
|
||||
Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`, `origin`, etc.
|
||||
|
||||
For a path-shaped surface (`path`, `external_directory`, or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
|
||||
For a path-shaped surface (`path` or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
|
||||
|
||||
For the `bash` surface, a `value` containing a chained or nested command (joined by `&&`, `||`, `;`, `|`, `&`, or newlines, or nested in a command substitution/subshell) is decomposed into its command-pattern units and resolved most-restrictive (`deny` > `ask` > `allow`), at parity with the enforcement gate — so `cd /repo && npm install x` returns the decision of the `npm install x` unit, not the leading `cd`.
|
||||
A previously chained command that returned `allow` (riding an allowed leading command) may therefore now return `deny`/`ask`.
|
||||
@@ -235,7 +235,7 @@ This is the reference implementation for the seam — see `src/builtin-tool-inpu
|
||||
|
||||
#### `registerToolAccessExtractor`
|
||||
|
||||
Declare the filesystem path a tool will access so the cross-cutting `path` and `external_directory` gates can evaluate it.
|
||||
Declare the local filesystem path a tool will access so the cross-cutting `path` gate can evaluate it; an extractor may also suppress local-path inference for remote paths.
|
||||
|
||||
```typescript
|
||||
registerToolAccessExtractor(
|
||||
@@ -360,7 +360,7 @@ Nested rather than flattened so the event and the prompt payload share one shape
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent |
|
||||
| `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name |
|
||||
| `surface` | `string` | The **gate** surface the rule fired on — `"path"`, `"bash"`, or a tool name |
|
||||
| `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped |
|
||||
| `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name |
|
||||
| `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name |
|
||||
@@ -372,7 +372,7 @@ Nested rather than flattened so the event and the prompt payload share one shape
|
||||
|
||||
The top-level `surface` and `request.surface` are two different facts and both belong on the event.
|
||||
The top-level one is the **display** projection — the child's tool name, what a notification shows.
|
||||
`request.surface` is the **gate** surface the rule fired on: a `read` of a path outside the working directory displays as `"read"` and gates on `"external_directory"`.
|
||||
`request.surface` is the **gate** surface the rule fired on. Paths outside the working directory have no special surface; they use the same `path` and tool surfaces as other paths.
|
||||
|
||||
#### `ForwardedPromptContext`
|
||||
|
||||
@@ -410,7 +410,7 @@ pi.events.on("permissions:decision", (raw) => {
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `requestId` | `string` | Id of the permission request this decision resolves |
|
||||
| `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
|
||||
| `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"path"`, etc.) |
|
||||
| `value` | `string` | Value evaluated (command, tool name, skill name, path) |
|
||||
| `result` | `"allow" \| "deny"` | Final outcome |
|
||||
| `resolution` | `string` | How the outcome was reached (see table below) |
|
||||
|
||||
Reference in New Issue
Block a user