@gotgenes/pi-permission-system 15.0.1 → 16.0.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,36 @@ 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
+ ## [16.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v15.1.0...pi-permission-system-v16.0.0) (2026-06-21)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** the bash permission gate fails closed. An internal gate error blocks the tool (with a gate_error review-log entry) instead of running it ungated, and a non-empty unparseable bash command resolves to ask instead of riding a permissive top-level "*". To opt back into permissive bash behavior, set an explicit "bash": { "*": "allow" } policy.
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** cut a major release for the fail-closed gate change ([#452](https://github.com/gotgenes/pi-packages/issues/452)) ([c7451cd](https://github.com/gotgenes/pi-packages/commit/c7451cd5fdcbc262a65863e26d5d56e24dda715e))
18
+
19
+ ## [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)
20
+
21
+
22
+ ### Features
23
+
24
+ * **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))
25
+ * **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))
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **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))
31
+ * **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))
32
+
33
+
34
+ ### Documentation
35
+
36
+ * **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))
37
+
8
38
  ## [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)
9
39
 
10
40
 
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
  },
@@ -107,6 +109,15 @@ Within a surface map like `bash` or `mcp`, **last matching rule wins** — put b
107
109
 
108
110
  For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
109
111
 
112
+ ## Upgrading
113
+
114
+ ### 16.0.0 — the bash gate now fails closed
115
+
116
+ The permission gate fails closed: an internal gate error blocks the tool (with a `gate_error` review-log entry) instead of running it ungated, and a non-empty bash command that cannot be parsed resolves to `ask` (sentinel `<unparseable-bash-command>`) rather than falling through to a permissive top-level `*`.
117
+ Commands that previously slipped through silently on the error or empty-parse path now block or prompt.
118
+
119
+ If you relied on the old permissive behavior for bash, set an explicit permissive bash policy — `"bash": { "*": "allow" }` — which also suppresses the new startup warning emitted when a top-level `"*": "allow"` leaves bash ungated.
120
+
110
121
  ## Documentation
111
122
 
112
123
  | Document | Contents |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "15.0.1",
3
+ "version": "16.0.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
+ }
@@ -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,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
  }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { memoizeAsyncWithRetry } from "#src/async-cache";
4
+
5
+ describe("memoizeAsyncWithRetry", () => {
6
+ it("invokes the factory once and shares the resolved value across calls", async () => {
7
+ const factory = vi.fn<() => Promise<number>>().mockResolvedValue(42);
8
+ const memoized = memoizeAsyncWithRetry(factory);
9
+
10
+ const results = await Promise.all([memoized(), memoized(), memoized()]);
11
+
12
+ expect(results).toEqual([42, 42, 42]);
13
+ expect(factory).toHaveBeenCalledTimes(1);
14
+ });
15
+
16
+ it("caches the same promise instance on success", async () => {
17
+ const factory = vi.fn<() => Promise<string>>().mockResolvedValue("parser");
18
+ const memoized = memoizeAsyncWithRetry(factory);
19
+
20
+ await memoized();
21
+ await memoized();
22
+
23
+ expect(factory).toHaveBeenCalledTimes(1);
24
+ });
25
+
26
+ it("surfaces the rejection to the caller each time the factory fails", async () => {
27
+ const error = new Error("init failed");
28
+ const factory = vi.fn<() => Promise<number>>().mockRejectedValue(error);
29
+ const memoized = memoizeAsyncWithRetry(factory);
30
+
31
+ await expect(memoized()).rejects.toThrow("init failed");
32
+ await expect(memoized()).rejects.toThrow("init failed");
33
+ });
34
+
35
+ it("drops a rejected result so the next call re-invokes the factory", async () => {
36
+ const factory = vi
37
+ .fn<() => Promise<number>>()
38
+ .mockRejectedValueOnce(new Error("transient"))
39
+ .mockResolvedValue(7);
40
+ const memoized = memoizeAsyncWithRetry(factory);
41
+
42
+ await expect(memoized()).rejects.toThrow("transient");
43
+ const recovered = await memoized();
44
+
45
+ expect(recovered).toBe(7);
46
+ expect(factory).toHaveBeenCalledTimes(2);
47
+ });
48
+ });
@@ -634,7 +634,10 @@ describe("loadAndMergeConfigs", () => {
634
634
  });
635
635
 
636
636
  const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
637
- expect(result.issues).toEqual([]);
637
+ // The merged config leaves a permissive top-level '*' with no bash '*' policy,
638
+ // so the bash-fallback footgun warning is expected.
639
+ expect(result.issues).toHaveLength(1);
640
+ expect(result.issues[0]).toContain("bash");
638
641
  expect(result.merged.debugLog).toBe(true);
639
642
  expect(result.merged.permission).toEqual({
640
643
  "*": "allow",
@@ -716,4 +719,22 @@ describe("loadAndMergeConfigs", () => {
716
719
  true,
717
720
  );
718
721
  });
722
+
723
+ it("warns when the merged config leaves bash inheriting a permissive top-level '*'", () => {
724
+ writeGlobal({
725
+ permission: { "*": "allow", read: "allow" },
726
+ });
727
+
728
+ const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
729
+ expect(result.issues.some((i) => i.includes("bash"))).toBe(true);
730
+ });
731
+
732
+ it("does not warn about bash fallback when bash is explicitly gated", () => {
733
+ writeGlobal({
734
+ permission: { "*": "allow", bash: { "*": "ask" } },
735
+ });
736
+
737
+ const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
738
+ expect(result.issues).toEqual([]);
739
+ });
719
740
  });