@gotgenes/pi-permission-system 15.0.0 → 15.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [15.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v15.0.1...pi-permission-system-v15.1.0) (2026-06-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** trace tool-call decisions and emit a session summary ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([528e340](https://github.com/gotgenes/pi-packages/commit/528e340ae38a6b2f431dac1ab92642c1af72c0ac))
14
+ * **pi-permission-system:** warn when a permissive top-level "*" leaves bash ungated ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([8ef8d0f](https://github.com/gotgenes/pi-packages/commit/8ef8d0fdf39297817c57968f0e345d79c6369d3a))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **pi-permission-system:** prompt instead of allowing an unparseable bash command ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([538bac1](https://github.com/gotgenes/pi-packages/commit/538bac12e343d613f2e980dabb516a880b90f3fe))
20
+ * **pi-permission-system:** retry tree-sitter parser init instead of caching a rejected promise ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([468facd](https://github.com/gotgenes/pi-packages/commit/468facd50e9f9ee986121f76546c368851b14edb))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **pi-permission-system:** document fail-closed gate behavior and bash fallback warning ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([fbb2844](https://github.com/gotgenes/pi-packages/commit/fbb28449afe9d92934769499d874c1cb93241c1b))
26
+
27
+ ## [15.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v15.0.0...pi-permission-system-v15.0.1) (2026-06-20)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **permission-system:** bind session approval for current-directory files ([#438](https://github.com/gotgenes/pi-packages/issues/438)) ([083a8e8](https://github.com/gotgenes/pi-packages/commit/083a8e8d9c2a4f6c49af158677d8669b4f099d9f))
33
+
8
34
  ## [15.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v14.0.1...pi-permission-system-v15.0.0) (2026-06-20)
9
35
 
10
36
 
package/README.md CHANGED
@@ -19,6 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command prompts (`ask`) rather than passing silently
22
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
23
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
24
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
@@ -44,6 +45,7 @@ pi install npm:@gotgenes/pi-permission-system
44
45
  "*.env.example": "allow"
45
46
  },
46
47
  "bash": {
48
+ "*": "ask",
47
49
  "rm -rf *": "deny",
48
50
  "sudo *": "ask"
49
51
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "15.0.0",
3
+ "version": "15.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Memoize an async factory, but drop a rejected result so the next call
3
+ * retries.
4
+ *
5
+ * On success the resolved promise is cached and shared across all callers (the
6
+ * factory runs once). On failure the cache is cleared before the rejection is
7
+ * re-thrown, so a transient init failure does not poison the memo for the
8
+ * process lifetime — the next call re-invokes the factory.
9
+ */
10
+ export function memoizeAsyncWithRetry<T>(
11
+ factory: () => Promise<T>,
12
+ ): () => Promise<T> {
13
+ let cached: Promise<T> | null = null;
14
+ return () => {
15
+ cached ??= factory().catch((error: unknown) => {
16
+ cached = null; // poisoned result cleared → next call re-attempts
17
+ throw error;
18
+ });
19
+ return cached;
20
+ };
21
+ }
@@ -365,6 +365,9 @@ export function loadAndMergeConfigs(
365
365
  const projectConfig = projectResult.config;
366
366
  merged = mergeUnifiedConfigs(merged, projectConfig);
367
367
 
368
+ const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
369
+ if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
370
+
368
371
  return {
369
372
  global: globalConfig,
370
373
  project: projectConfig,
@@ -373,6 +376,38 @@ export function loadAndMergeConfigs(
373
376
  };
374
377
  }
375
378
 
379
+ /**
380
+ * Detect the config footgun where a permissive top-level `*: allow` leaves the
381
+ * bash surface ungated, so every bash command silently inherits `allow`.
382
+ *
383
+ * Returns one warning string when `permission["*"] === "allow"` and the `bash`
384
+ * surface neither is a bare string (shorthand for `{ "*": … }`) nor an object
385
+ * map with an explicit `"*"` key. Returns `undefined` otherwise. The detector
386
+ * is pure: it takes the merged permission map and returns a message; the caller
387
+ * owns pushing it onto the issue list.
388
+ */
389
+ export function detectPermissiveBashFallback(
390
+ permission: FlatPermissionConfig | undefined,
391
+ ): string | undefined {
392
+ if (permission?.["*"] !== "allow") return undefined;
393
+
394
+ // The Record index signature reports an absent surface as the value type, not
395
+ // `undefined`; read through a Partial view so the absent-bash guard is honest
396
+ // (an unguarded Object.hasOwn(undefined, …) would throw at runtime).
397
+ const surfaces: Partial<FlatPermissionConfig> = permission;
398
+ const bash = surfaces.bash;
399
+ // A bare string surface is shorthand for `{ "*": action }` — explicitly gated.
400
+ if (typeof bash === "string") return undefined;
401
+ // An object map with an explicit `"*"` key is explicitly gated.
402
+ if (bash && Object.hasOwn(bash, "*")) return undefined;
403
+
404
+ return (
405
+ "Permission config sets a permissive top-level '*': 'allow' with no 'bash' '*' policy, " +
406
+ "so bash commands silently inherit 'allow'. Set an explicit 'bash' policy " +
407
+ '(e.g. "bash": { "*": "ask" }) to gate bash commands.'
408
+ );
409
+ }
410
+
376
411
  /**
377
412
  * Load and normalize a unified config file.
378
413
  * Returns an empty config with no issues if the file does not exist.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Records the per-call terminal decision so an evaluated-and-allowed call is
3
+ * distinguishable from a never-evaluated one. The fail-closed boundary owns the
4
+ * recorder and calls exactly one of `recordDecision` / `recordError` per call.
5
+ */
6
+ export interface DecisionRecorder {
7
+ /** Record a terminal allow/block decision (also bumps the tool-call count). */
8
+ recordDecision(action: "allow" | "block"): void;
9
+ /** Record a gate error that blocked fail-closed (also bumps the count). */
10
+ recordError(): void;
11
+ }
12
+
13
+ /** Narrow logging surface the summary needs: a debug line and a warning. */
14
+ export interface AuditLogger {
15
+ debug(event: string, details?: Record<string, unknown>): void;
16
+ warn(message: string): void;
17
+ }
18
+
19
+ /** Narrow surface the session-shutdown handler depends on. */
20
+ export interface DecisionSummaryWriter {
21
+ writeSummary(logger: AuditLogger): void;
22
+ }
23
+
24
+ /**
25
+ * In-process, per-session decision counters.
26
+ *
27
+ * The boundary produces exactly one terminal decision per tool call, so
28
+ * `toolCalls` must always equal `allowed + blocked + errors`. `writeSummary`
29
+ * emits the counters on `session_shutdown` and flags any mismatch as a cheap
30
+ * structural self-check — a mismatch means a code path re-opened a silent
31
+ * (never-recorded) exit.
32
+ */
33
+ export class DecisionAudit implements DecisionRecorder {
34
+ private toolCalls = 0;
35
+ private allowed = 0;
36
+ private blocked = 0;
37
+ private errors = 0;
38
+
39
+ recordDecision(action: "allow" | "block"): void {
40
+ this.toolCalls++;
41
+ if (action === "allow") {
42
+ this.allowed++;
43
+ } else {
44
+ this.blocked++;
45
+ }
46
+ }
47
+
48
+ recordError(): void {
49
+ this.toolCalls++;
50
+ this.errors++;
51
+ }
52
+
53
+ /**
54
+ * Emit one `permission.session_summary` debug line with the counters. When
55
+ * `toolCalls !== allowed + blocked + errors`, also emit a warning — the
56
+ * invariant violation means a tool call resolved without a recorded terminal
57
+ * decision (a re-opened silent path).
58
+ */
59
+ writeSummary(logger: AuditLogger): void {
60
+ const counts = {
61
+ toolCalls: this.toolCalls,
62
+ allowed: this.allowed,
63
+ blocked: this.blocked,
64
+ errors: this.errors,
65
+ };
66
+ logger.debug("permission.session_summary", counts);
67
+ if (this.toolCalls !== this.allowed + this.blocked + this.errors) {
68
+ logger.warn(
69
+ `[pi-permission-system] decision audit invariant violated: ${this.toolCalls} tool calls != ` +
70
+ `${this.allowed} allowed + ${this.blocked} blocked + ${this.errors} errors. ` +
71
+ "A tool call resolved without a recorded terminal decision.",
72
+ );
73
+ }
74
+ }
75
+ }
@@ -19,9 +19,13 @@ import type { PermissionCheckResult } from "#src/types";
19
19
  * `commandContext` (set only for a nested command), so the prompt,
20
20
  * session-approval suggestion, and decision event scope to that command.
21
21
  *
22
- * When `commands` is empty (an empty command, a comment, or a bare compound
23
- * statement), the whole `command` is evaluated as before, so the surface is
24
- * never weaker than the previous behavior.
22
+ * When `commands` is empty there are two cases. A trivially-empty command (an
23
+ * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
24
+ * so the whole `command` is resolved as before. A non-empty command that parsed
25
+ * to zero command units (a parse anomaly or an opaque program) fails closed to
26
+ * a synthetic `ask` so a permissive top-level `*` cannot silently allow an
27
+ * unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
28
+ * the empty-parse path) — #452.
25
29
  *
26
30
  * Pure and synchronous: the (async, tree-sitter) parse happens once in the
27
31
  * handler, which passes the decomposed `commands` here.
@@ -32,6 +36,20 @@ export function resolveBashCommandCheck(
32
36
  agentName: string | undefined,
33
37
  resolver: ScopedPermissionResolver,
34
38
  ): PermissionCheckResult {
39
+ if (commands.length === 0) {
40
+ if (isTriviallyEmptyCommand(command)) {
41
+ return resolver.resolve("bash", { command }, agentName);
42
+ }
43
+ return {
44
+ state: "ask",
45
+ toolName: "bash",
46
+ source: "bash",
47
+ origin: "builtin",
48
+ command,
49
+ matchedPattern: "<unparseable-bash-command>",
50
+ };
51
+ }
52
+
35
53
  const results = commands.map((cmd) => {
36
54
  const result = resolver.resolve("bash", { command: cmd.text }, agentName);
37
55
  return cmd.context ? { ...result, commandContext: cmd.context } : result;
@@ -41,3 +59,17 @@ export function resolveBashCommandCheck(
41
59
  resolver.resolve("bash", { command }, agentName)
42
60
  );
43
61
  }
62
+
63
+ /**
64
+ * True when a command has genuinely nothing to gate: it is empty,
65
+ * whitespace-only, or contains only comment lines (every non-blank line starts
66
+ * with `#`). Such a command yields zero command units legitimately, so the
67
+ * whole-string resolve is safe rather than a parse anomaly.
68
+ */
69
+ function isTriviallyEmptyCommand(command: string): boolean {
70
+ const lines = command
71
+ .split("\n")
72
+ .map((line) => line.trim())
73
+ .filter((line) => line.length > 0);
74
+ return lines.every((line) => line.startsWith("#"));
75
+ }
@@ -40,9 +40,14 @@ export function describeBashPathGate(
40
40
  if (candidates.length === 0) return null;
41
41
  const tokens = candidates.map(({ token }) => token);
42
42
 
43
- // Tokens whose resolved state needs a check (deny/ask), paired with the
44
- // token that produced them so the descriptor can derive its pattern.
45
- const uncovered: Array<{ token: string; check: PermissionCheckResult }> = [];
43
+ // Tokens whose resolved state needs a check (deny/ask), paired with the raw
44
+ // token (prompt/decision display) and its policy values (the first of which
45
+ // is the canonical absolute path the approval pattern is derived from).
46
+ const uncovered: Array<{
47
+ token: string;
48
+ policyValues: readonly string[];
49
+ check: PermissionCheckResult;
50
+ }> = [];
46
51
  let allSessionCovered = true;
47
52
 
48
53
  for (const { token, policyValues } of candidates) {
@@ -64,11 +69,11 @@ export function describeBashPathGate(
64
69
  }
65
70
 
66
71
  if (check.state === "deny") {
67
- uncovered.push({ token, check });
72
+ uncovered.push({ token, policyValues, check });
68
73
  break; // Short-circuit on deny.
69
74
  }
70
75
  if (check.state === "ask") {
71
- uncovered.push({ token, check });
76
+ uncovered.push({ token, policyValues, check });
72
77
  }
73
78
  }
74
79
 
@@ -93,14 +98,19 @@ export function describeBashPathGate(
93
98
 
94
99
  // Pick the most restrictive (deny > ask > allow, first-wins) uncovered token.
95
100
  const worstCheck = pickMostRestrictive(uncovered.map(({ check }) => check));
96
- const worstToken = worstCheck
97
- ? (uncovered.find(({ check }) => check === worstCheck)?.token ?? null)
98
- : null;
101
+ const worstEntry = worstCheck
102
+ ? uncovered.find(({ check }) => check === worstCheck)
103
+ : undefined;
104
+ const worstToken = worstEntry?.token ?? null;
99
105
 
100
106
  // All tokens evaluate to allow — no restriction.
101
- if (!worstCheck || !worstToken) return null;
107
+ if (!worstCheck || !worstToken || !worstEntry) return null;
102
108
 
103
- const pattern = deriveApprovalPattern(worstToken);
109
+ // Derive the pattern from the canonical absolute policy value (the cd-aware
110
+ // resolved path), so it matches the values a later call produces. Falls back
111
+ // to the raw token only when no base was resolvable (no cwd / unknown cd).
112
+ const approvalBase = worstEntry.policyValues[0] ?? worstToken;
113
+ const pattern = deriveApprovalPattern(approvalBase);
104
114
  const askMessage = formatPathAskPrompt(
105
115
  tcc.toolName,
106
116
  worstToken,
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import { memoizeAsyncWithRetry } from "#src/async-cache";
3
4
  import { canonicalizePath } from "#src/canonicalize-path";
4
5
  import {
5
6
  classifyTokenAsPathCandidate,
@@ -37,8 +38,6 @@ interface TSParser {
37
38
  delete(): void;
38
39
  }
39
40
 
40
- let parserPromise: Promise<TSParser> | null = null;
41
-
42
41
  async function initParser(): Promise<TSParser> {
43
42
  // Use named imports — web-tree-sitter exports Parser as a named class.
44
43
  const { Parser, Language } = await import("web-tree-sitter");
@@ -53,10 +52,10 @@ async function initParser(): Promise<TSParser> {
53
52
  return parser;
54
53
  }
55
54
 
56
- function getParser(): Promise<TSParser> {
57
- parserPromise ??= initParser();
58
- return parserPromise;
59
- }
55
+ // Memoize on success but drop a rejected result so a transient init failure
56
+ // (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
57
+ // the parser for the process lifetime.
58
+ const getParser = memoizeAsyncWithRetry(initParser);
60
59
 
61
60
  // ── Parsed bash command representation ───────────────────────────────────────
62
61
 
@@ -1,4 +1,4 @@
1
- import { getToolInputPath } from "#src/path-utils";
1
+ import { getToolInputPath, normalizePathForComparison } from "#src/path-utils";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -35,7 +35,12 @@ export function describePathGate(
35
35
  // "path" key should not trigger path-level prompts (#58).
36
36
  if (check.matchedPattern === undefined) return null;
37
37
 
38
- const pattern = deriveApprovalPattern(filePath);
38
+ // Resolve to the canonical (cwd-anchored, absolute) path so the approval
39
+ // pattern matches the policy values a later call produces.
40
+ const approvalPath = tcc.cwd
41
+ ? normalizePathForComparison(filePath, tcc.cwd)
42
+ : filePath;
43
+ const pattern = deriveApprovalPattern(approvalPath);
39
44
 
40
45
  const descriptor: GateDescriptor = {
41
46
  surface: "path",
@@ -1,4 +1,8 @@
1
- import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
1
+ import {
2
+ getPathBearingToolPath,
3
+ normalizePathForComparison,
4
+ PATH_BEARING_TOOLS,
5
+ } from "#src/path-utils";
2
6
  import { suggestSessionPattern } from "#src/pattern-suggest";
3
7
  import { formatAskPrompt } from "#src/permission-prompts";
4
8
  import { SessionApproval } from "#src/session-approval";
@@ -12,7 +16,9 @@ import type { ToolCallContext } from "./types";
12
16
  * Derive the value used for session-approval pattern suggestions.
13
17
  *
14
18
  * Bash → command string; MCP → qualified target;
15
- * path-bearing tools → file path; others catch-all wildcard.
19
+ * path-bearing tools → the file path resolved to its canonical (cwd-anchored,
20
+ * absolute) form so the suggested pattern matches the policy values a later
21
+ * call produces; others → catch-all wildcard.
16
22
  */
17
23
  function deriveSuggestionValue(
18
24
  tcc: ToolCallContext,
@@ -20,7 +26,9 @@ function deriveSuggestionValue(
20
26
  ): string {
21
27
  if (tcc.toolName === "bash") return check.command ?? "";
22
28
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
23
- return getPathBearingToolPath(tcc.toolName, tcc.input) ?? "*";
29
+ const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
+ if (path === null) return "*";
31
+ return tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path;
24
32
  }
25
33
 
26
34
  /**
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import type { DecisionSummaryWriter } from "#src/decision-audit";
3
4
  import type { PermissionResolver } from "#src/permission-resolver";
4
5
  import type { PermissionSession } from "#src/permission-session";
5
6
  import type { ServiceLifecycle } from "#src/service-lifecycle";
@@ -26,6 +27,7 @@ interface ResourcesDiscoverPayload {
26
27
  * `activate` publishes (skipped for registered subagent children) and emits
27
28
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
28
29
  * - `logger` — injected directly; replaces the former `session.logger` reach-through
30
+ * - `audit` — per-session decision counters; its summary is written on shutdown
29
31
  */
30
32
  export class SessionLifecycleHandler {
31
33
  constructor(
@@ -33,6 +35,7 @@ export class SessionLifecycleHandler {
33
35
  private readonly resolver: PermissionResolver,
34
36
  private readonly serviceLifecycle: ServiceLifecycle,
35
37
  private readonly logger: SessionLogger,
38
+ private readonly audit: DecisionSummaryWriter,
36
39
  ) {}
37
40
 
38
41
  handleSessionStart(
@@ -84,6 +87,7 @@ export class SessionLifecycleHandler {
84
87
  if (ctx) {
85
88
  ctx.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
86
89
  }
90
+ this.audit.writeSummary(this.logger);
87
91
  this.session.shutdown();
88
92
  this.serviceLifecycle.teardown();
89
93
  return Promise.resolve();
@@ -20,7 +20,7 @@ import type {
20
20
  SkillInputGatePipeline,
21
21
  } from "./gates/skill-input-gate-pipeline";
22
22
  import type { ToolCallGatePipeline } from "./gates/tool-call-gate-pipeline";
23
- import type { ToolCallContext } from "./gates/types";
23
+ import type { GateOutcome, ToolCallContext } from "./gates/types";
24
24
 
25
25
  /** Minimal subset of InputEvent used by handleInput. */
26
26
  interface InputPayload {
@@ -49,12 +49,12 @@ export class PermissionGateHandler {
49
49
  async handleToolCall(
50
50
  event: unknown,
51
51
  ctx: ExtensionContext,
52
- ): Promise<{ block?: true; reason?: string }> {
52
+ ): Promise<GateOutcome> {
53
53
  this.session.activate(ctx);
54
54
 
55
55
  const validation = validateRequestedTool(event, this.toolRegistry.getAll());
56
56
  if (validation.status === "block") {
57
- return { block: true, reason: validation.reason };
57
+ return { action: "block", reason: validation.reason };
58
58
  }
59
59
  const toolName = validation.toolName;
60
60
 
@@ -74,10 +74,7 @@ export class PermissionGateHandler {
74
74
  cwd: ctx.cwd,
75
75
  };
76
76
 
77
- const outcome = await this.pipeline.evaluate(tcc, this.runner);
78
- return outcome.action === "block"
79
- ? { block: true, reason: outcome.reason }
80
- : {};
77
+ return await this.pipeline.evaluate(tcc, this.runner);
81
78
  }
82
79
 
83
80
  async handleInput(
@@ -0,0 +1,91 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { toRecord } from "#src/common";
4
+ import type { DecisionRecorder } from "#src/decision-audit";
5
+ import type { DecisionReporter } from "#src/decision-reporter";
6
+ import type { GateOutcome } from "./gates/types";
7
+
8
+ /** The SDK-facing result shape for a `tool_call` handler. */
9
+ type ToolCallResult = { block?: true; reason?: string };
10
+
11
+ /**
12
+ * Narrow debug surface for the per-call decision trace. The concrete logger
13
+ * self-gates on `debugLog`, so the boundary emits unconditionally and the
14
+ * entry is dropped when the toggle is off (no per-call spam in normal use).
15
+ */
16
+ export interface DecisionTracer {
17
+ debug(event: string, details?: Record<string, unknown>): void;
18
+ }
19
+
20
+ /**
21
+ * The only `tool_call` handler the SDK sees.
22
+ *
23
+ * Guarantees fail-closed: it owns the `try/catch → block` and is the sole place
24
+ * an internal {@link GateOutcome} is translated to the SDK result shape, so
25
+ * "we didn't decide" can never silently mean "allow."
26
+ *
27
+ * The SDK's `emitToolCall` (`@earendil-works/pi-coding-agent`
28
+ * `dist/core/extensions/runner.js`) awaits the registered handler with **no**
29
+ * try/catch — unlike `emitUserBash` directly below it, which catches and
30
+ * continues. A thrown gate therefore yields no `{ block: true }` and the
31
+ * command runs ungated with nothing logged. This boundary absorbs that throw,
32
+ * blocks, and writes a `gate_error` review-log entry.
33
+ *
34
+ * Fail-closed = **block** (not `ask`) for an unexpected exception: the command
35
+ * may be unknown and the prompt infrastructure itself may be what threw, so a
36
+ * hard block is the unambiguous safe outcome.
37
+ */
38
+ export function createFailClosedToolCall(
39
+ gate: (event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>,
40
+ reporter: DecisionReporter,
41
+ audit: DecisionRecorder,
42
+ tracer: DecisionTracer,
43
+ ): (event: unknown, ctx: ExtensionContext) => Promise<ToolCallResult> {
44
+ return async (event, ctx) => {
45
+ try {
46
+ const outcome = await gate(event, ctx);
47
+ audit.recordDecision(outcome.action);
48
+ tracer.debug("permission.decision", {
49
+ toolName: bestEffortToolName(event),
50
+ action: outcome.action,
51
+ ...(outcome.action === "block" ? { reason: outcome.reason } : {}),
52
+ });
53
+ return outcome.action === "block"
54
+ ? { block: true, reason: outcome.reason }
55
+ : {};
56
+ } catch (error) {
57
+ audit.recordError();
58
+ reporter.writeReviewLog("permission_request.blocked", {
59
+ toolName: bestEffortToolName(event),
60
+ command: bestEffortCommand(event),
61
+ resolution: "gate_error",
62
+ error: errorMessage(error),
63
+ });
64
+ return { block: true, reason: formatGateErrorReason(error) };
65
+ }
66
+ };
67
+ }
68
+
69
+ // ── Defensive event readers (never throw) ──────────────────────────────────
70
+
71
+ /** Best-effort tool name from a raw event; never throws. */
72
+ function bestEffortToolName(event: unknown): string {
73
+ const record = toRecord(event);
74
+ const name = record.name ?? record.toolName;
75
+ return typeof name === "string" && name ? name : "<unknown>";
76
+ }
77
+
78
+ /** Best-effort bash command from a raw event; never throws. */
79
+ function bestEffortCommand(event: unknown): string | undefined {
80
+ const record = toRecord(event);
81
+ const input = toRecord(record.input ?? record.arguments);
82
+ return typeof input.command === "string" ? input.command : undefined;
83
+ }
84
+
85
+ function errorMessage(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ function formatGateErrorReason(error: unknown): string {
90
+ return `Permission gate failed and blocked the tool call (fail-closed): ${errorMessage(error)}`;
91
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatt
4
4
  import { registerPermissionSystemCommand } from "./config-modal";
5
5
  import { getGlobalConfigPath } from "./config-paths";
6
6
  import { ConfigStore } from "./config-store";
7
+ import { DecisionAudit } from "./decision-audit";
7
8
  import { GateDecisionReporter } from "./decision-reporter";
8
9
  import { computeExtensionPaths } from "./extension-paths";
9
10
  import {
@@ -19,6 +20,7 @@ import {
19
20
  import { GateRunner } from "./handlers/gates/runner";
20
21
  import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
21
22
  import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
23
+ import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
22
24
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
23
25
  import { registerPermissionRpcHandlers } from "./permission-event-rpc";
24
26
  import { PermissionManager } from "./permission-manager";
@@ -163,11 +165,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
163
165
 
164
166
  const resolver = new PermissionResolver(permissionManager, sessionRules);
165
167
 
168
+ const audit = new DecisionAudit();
166
169
  const lifecycle = new SessionLifecycleHandler(
167
170
  session,
168
171
  resolver,
169
172
  serviceLifecycle,
170
173
  logger,
174
+ audit,
171
175
  );
172
176
  const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
173
177
 
@@ -197,5 +201,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
197
201
  pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
198
202
  pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
199
203
  pi.on("input", (event, ctx) => gates.handleInput(event, ctx));
200
- pi.on("tool_call", (event, ctx) => gates.handleToolCall(event, ctx));
204
+ pi.on(
205
+ "tool_call",
206
+ createFailClosedToolCall(
207
+ (event, ctx) => gates.handleToolCall(event, ctx),
208
+ reporter,
209
+ audit,
210
+ logger,
211
+ ),
212
+ );
201
213
  }
@@ -90,6 +90,10 @@ function buildLabel(pattern: string, surface: string): string {
90
90
  *
91
91
  * Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
92
92
  * to store in `SessionRules`, and a human-readable dialog label.
93
+ *
94
+ * `value` is expected to be the canonical (cwd-resolved, absolute) path for
95
+ * path surfaces — callers resolve it before suggesting, so the derived pattern
96
+ * matches the policy values a later tool call produces.
93
97
  */
94
98
  export function suggestSessionPattern(
95
99
  surface: string,
@@ -58,6 +58,11 @@ export class SessionRules implements SessionApprovalRecorder {
58
58
  *
59
59
  * For paths that already end with a separator (directories), the separator
60
60
  * is treated as the directory boundary and `*` is appended directly.
61
+ *
62
+ * The path is expected to be the canonical (cwd-resolved, absolute) form used
63
+ * for policy matching, so the derived pattern matches the same policy values a
64
+ * later tool call produces. Callers that hold a working directory resolve the
65
+ * path to that form first; the function itself stays free of cwd state.
61
66
  */
62
67
  export function deriveApprovalPattern(normalizedPath: string): string {
63
68
  // If the path already ends with a separator, it's a directory — glob its contents.