@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 +30 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/async-cache.ts +21 -0
- package/src/config-loader.ts +35 -0
- package/src/decision-audit.ts +75 -0
- package/src/handlers/gates/bash-command.ts +35 -3
- package/src/handlers/gates/bash-program.ts +5 -6
- package/src/handlers/lifecycle.ts +4 -0
- package/src/handlers/permission-gate-handler.ts +4 -7
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +13 -1
- package/test/async-cache.test.ts +48 -0
- package/test/config-loader.test.ts +22 -1
- package/test/decision-audit.test.ts +72 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/handlers/external-directory-integration.test.ts +24 -20
- package/test/handlers/external-directory-session-dedup.test.ts +4 -4
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +33 -6
- package/test/handlers/lifecycle.test.ts +9 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call.test.ts +18 -18
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
|
@@ -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
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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<
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
});
|