@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 +26 -0
- package/README.md +2 -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-path.ts +20 -10
- package/src/handlers/gates/bash-program.ts +5 -6
- package/src/handlers/gates/path.ts +7 -2
- package/src/handlers/gates/tool.ts +11 -3
- 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/src/pattern-suggest.ts +4 -0
- package/src/session-rules.ts +5 -0
- 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/gates/bash-path.test.ts +19 -0
- package/test/handlers/gates/path.test.ts +14 -0
- package/test/handlers/gates/tool.test.ts +34 -0
- 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/test/session-rules.test.ts +15 -0
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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
45
|
-
|
|
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
|
|
97
|
-
?
|
|
98
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
}
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -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,
|
package/src/session-rules.ts
CHANGED
|
@@ -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.
|