@gotgenes/pi-permission-system 5.6.2 → 5.6.3

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,15 @@ 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
+ ## [5.6.3](https://github.com/gotgenes/pi-permission-system/compare/v5.6.2...v5.6.3) (2026-05-07)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **retro:** add retro notes for issue [#109](https://github.com/gotgenes/pi-permission-system/issues/109) ([7d46cf4](https://github.com/gotgenes/pi-permission-system/commit/7d46cf4576d13d1d348355de88fb3dda6297be5a))
14
+ * update architecture for external-directory split ([#110](https://github.com/gotgenes/pi-permission-system/issues/110)) ([2e86fe7](https://github.com/gotgenes/pi-permission-system/commit/2e86fe79bc5de8076f775949824adadd4d366d7c))
15
+ * update plan for [#110](https://github.com/gotgenes/pi-permission-system/issues/110) after [#109](https://github.com/gotgenes/pi-permission-system/issues/109) landed ([3541d57](https://github.com/gotgenes/pi-permission-system/commit/3541d5739385b77ea8b21752595efd5cb75a6789))
16
+
8
17
  ## [5.6.2](https://github.com/gotgenes/pi-permission-system/compare/v5.6.1...v5.6.2) (2026-05-07)
9
18
 
10
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.6.2",
3
+ "version": "5.6.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,518 @@
1
+ import { createRequire } from "node:module";
2
+ import { basename } from "node:path";
3
+
4
+ import {
5
+ isPathOutsideWorkingDirectory,
6
+ normalizePathForComparison,
7
+ } from "./path-utils";
8
+
9
+ // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
10
+
11
+ /**
12
+ * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
13
+ * Defined locally so callers do not need to import web-tree-sitter types.
14
+ */
15
+ interface TSNode {
16
+ readonly type: string;
17
+ readonly text: string;
18
+ readonly childCount: number;
19
+ child(index: number): TSNode | null;
20
+ }
21
+
22
+ /**
23
+ * Minimal subset of web-tree-sitter's Parser used by this module.
24
+ */
25
+ interface TSParser {
26
+ parse(input: string): { rootNode: TSNode; delete(): void } | null;
27
+ delete(): void;
28
+ }
29
+
30
+ let parserPromise: Promise<TSParser> | null = null;
31
+
32
+ async function initParser(): Promise<TSParser> {
33
+ // Use named imports — web-tree-sitter exports Parser as a named class.
34
+ const { Parser, Language } = await import("web-tree-sitter");
35
+ const req = createRequire(import.meta.url);
36
+ const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
37
+ await Parser.init({ locateFile: () => treeSitterWasm });
38
+
39
+ const parser = new Parser();
40
+ const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
41
+ const bash = await Language.load(bashWasm);
42
+ parser.setLanguage(bash);
43
+ return parser as TSParser;
44
+ }
45
+
46
+ function getParser(): Promise<TSParser> {
47
+ if (!parserPromise) {
48
+ parserPromise = initParser();
49
+ }
50
+ return parserPromise;
51
+ }
52
+
53
+ /**
54
+ * Reset the cached parser promise. Only used by tests to avoid
55
+ * cross-test pollution or to inject a mock parser.
56
+ */
57
+ export function resetParserForTesting(): void {
58
+ parserPromise = null;
59
+ }
60
+
61
+ // ── AST walker ─────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Node types whose subtrees must never be descended into for
65
+ * path extraction — their text content is not a command argument.
66
+ */
67
+ const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
68
+
69
+ /**
70
+ * Resolve the "shell value" of an argument node — the string the shell
71
+ * would pass to the command after quote removal.
72
+ *
73
+ * - `word` → `.text` (already unquoted)
74
+ * - `raw_string` → strip surrounding single quotes
75
+ * - `string` → strip surrounding double quotes, concatenate children text
76
+ * - `concatenation` → concatenate resolved children
77
+ * - other → `.text` as fallback
78
+ */
79
+ function resolveNodeText(node: TSNode): string {
80
+ switch (node.type) {
81
+ case "word":
82
+ return node.text;
83
+ case "raw_string": {
84
+ // Strip surrounding single quotes: 'content' → content
85
+ const t = node.text;
86
+ if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
87
+ return t.slice(1, -1);
88
+ }
89
+ return t;
90
+ }
91
+ case "string": {
92
+ // Double-quoted string: concatenate the resolved text of inner children,
93
+ // skipping the quote-delimiter nodes (literal `"`).
94
+ let result = "";
95
+ for (let i = 0; i < node.childCount; i++) {
96
+ const child = node.child(i);
97
+ if (!child) continue;
98
+ // Skip the literal `"` delimiters
99
+ if (child.type === '"') continue;
100
+ result += resolveNodeText(child);
101
+ }
102
+ return result;
103
+ }
104
+ case "string_content":
105
+ case "simple_expansion":
106
+ case "expansion":
107
+ return node.text;
108
+ case "concatenation": {
109
+ let result = "";
110
+ for (let i = 0; i < node.childCount; i++) {
111
+ const child = node.child(i);
112
+ if (!child) continue;
113
+ result += resolveNodeText(child);
114
+ }
115
+ return result;
116
+ }
117
+ default:
118
+ return node.text;
119
+ }
120
+ }
121
+
122
+ // ── Pattern-first command config ───────────────────────────────────────────
123
+
124
+ interface PatternCommandConfig {
125
+ /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
126
+ readonly argConsumingFlags: ReadonlySet<string>;
127
+ /** Flags that consume the next argument as a file path */
128
+ readonly fileConsumingFlags: ReadonlySet<string>;
129
+ /**
130
+ * Number of leading positional arguments that are patterns/scripts, not paths.
131
+ * Default: 1 (covers sed, awk, grep, rg).
132
+ * sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
133
+ */
134
+ readonly patternPositionals?: number;
135
+ }
136
+
137
+ /**
138
+ * Commands whose first N positional arguments are inline patterns/scripts,
139
+ * not filesystem paths. The map stores per-command flag configuration so
140
+ * the walker can correctly identify which arguments are consumed by flags
141
+ * vs. which are positional.
142
+ */
143
+ const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
144
+ new Map([
145
+ [
146
+ "sed",
147
+ {
148
+ argConsumingFlags: new Set(["-e", "-i"]),
149
+ fileConsumingFlags: new Set(["-f"]),
150
+ },
151
+ ],
152
+ [
153
+ "awk",
154
+ {
155
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
156
+ fileConsumingFlags: new Set(["-f"]),
157
+ },
158
+ ],
159
+ [
160
+ "gawk",
161
+ {
162
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
163
+ fileConsumingFlags: new Set(["-f"]),
164
+ },
165
+ ],
166
+ [
167
+ "nawk",
168
+ {
169
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
170
+ fileConsumingFlags: new Set(["-f"]),
171
+ },
172
+ ],
173
+ [
174
+ "grep",
175
+ {
176
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
177
+ fileConsumingFlags: new Set(["-f"]),
178
+ },
179
+ ],
180
+ [
181
+ "egrep",
182
+ {
183
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
184
+ fileConsumingFlags: new Set(["-f"]),
185
+ },
186
+ ],
187
+ [
188
+ "fgrep",
189
+ {
190
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
191
+ fileConsumingFlags: new Set(["-f"]),
192
+ },
193
+ ],
194
+ [
195
+ "rg",
196
+ {
197
+ argConsumingFlags: new Set([
198
+ "-e",
199
+ "-A",
200
+ "-B",
201
+ "-C",
202
+ "-m",
203
+ "-g",
204
+ "-t",
205
+ "-T",
206
+ "-j",
207
+ "-M",
208
+ "-r",
209
+ "-E",
210
+ ]),
211
+ fileConsumingFlags: new Set(["-f"]),
212
+ },
213
+ ],
214
+ [
215
+ "sd",
216
+ {
217
+ argConsumingFlags: new Set(["-n", "-f"]),
218
+ fileConsumingFlags: new Set([]),
219
+ patternPositionals: 2,
220
+ },
221
+ ],
222
+ ]);
223
+
224
+ /** Node types that represent argument values in the AST. */
225
+ const ARG_NODE_TYPES = new Set([
226
+ "word",
227
+ "concatenation",
228
+ "string",
229
+ "raw_string",
230
+ ]);
231
+
232
+ /**
233
+ * Extract the command name from a `command` node.
234
+ * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
235
+ * if the command name cannot be determined (e.g. variable expansion).
236
+ */
237
+ function extractCommandName(node: TSNode): string | undefined {
238
+ for (let i = 0; i < node.childCount; i++) {
239
+ const child = node.child(i);
240
+ if (!child) continue;
241
+ if (child.type === "command_name") {
242
+ const text = resolveNodeText(child);
243
+ return text ? basename(text) : undefined;
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ /**
250
+ * Collect path-candidate tokens from a command known to have
251
+ * pattern/script arguments in leading positional slots.
252
+ *
253
+ * Uses position-based skipping: the first N positional arguments
254
+ * (where N = patternPositionals, default 1) are assumed to be
255
+ * inline patterns/scripts and are skipped. Remaining positional
256
+ * arguments are collected as path candidates.
257
+ *
258
+ * Flags listed in `argConsumingFlags` consume the next argument
259
+ * (skipped). Flags in `fileConsumingFlags` consume the next
260
+ * argument as a file path (collected). The flags `-e` and `-f`
261
+ * additionally signal that an explicit script was provided via
262
+ * flag, so no inline positional script is expected.
263
+ */
264
+ function collectPatternCommandTokens(
265
+ node: TSNode,
266
+ tokens: string[],
267
+ config: PatternCommandConfig,
268
+ ): void {
269
+ const patternPositionals = config.patternPositionals ?? 1;
270
+ let hasExplicitScript = false;
271
+ let positionalsSeen = 0;
272
+ let nextArgAction: "skip" | "extract" | null = null;
273
+ let pastEndOfFlags = false;
274
+
275
+ for (let i = 0; i < node.childCount; i++) {
276
+ const child = node.child(i);
277
+ if (!child) continue;
278
+
279
+ // Skip command_name and variable_assignment nodes.
280
+ if (child.type === "command_name" || child.type === "variable_assignment")
281
+ continue;
282
+
283
+ // Only process argument-like nodes; recurse into others
284
+ // (e.g. command_substitution) for nested commands.
285
+ if (!ARG_NODE_TYPES.has(child.type)) {
286
+ collectPathCandidateTokens(child, tokens);
287
+ continue;
288
+ }
289
+
290
+ const text = resolveNodeText(child);
291
+
292
+ // Handle consumed argument from previous flag.
293
+ if (nextArgAction === "skip") {
294
+ nextArgAction = null;
295
+ continue;
296
+ }
297
+ if (nextArgAction === "extract") {
298
+ tokens.push(text);
299
+ nextArgAction = null;
300
+ continue;
301
+ }
302
+
303
+ // Flag detection (only before "--" end-of-flags marker).
304
+ if (
305
+ !pastEndOfFlags &&
306
+ child.type === "word" &&
307
+ text.startsWith("-") &&
308
+ text.length > 1
309
+ ) {
310
+ if (text === "--") {
311
+ pastEndOfFlags = true;
312
+ continue;
313
+ }
314
+ if (config.argConsumingFlags.has(text)) {
315
+ nextArgAction = "skip";
316
+ if (text === "-e" || text === "-f") {
317
+ hasExplicitScript = true;
318
+ }
319
+ continue;
320
+ }
321
+ if (config.fileConsumingFlags.has(text)) {
322
+ nextArgAction = "extract";
323
+ hasExplicitScript = true;
324
+ continue;
325
+ }
326
+ // Regular flag — skip it.
327
+ continue;
328
+ }
329
+
330
+ // Positional argument.
331
+ if (!hasExplicitScript && positionalsSeen < patternPositionals) {
332
+ positionalsSeen++;
333
+ continue; // Skip: this is an inline pattern/script.
334
+ }
335
+
336
+ // File argument — collect as path candidate.
337
+ tokens.push(text);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Recursively visit the AST and collect resolved text of nodes that
343
+ * represent command arguments or redirect destinations.
344
+ *
345
+ * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
346
+ *
347
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
348
+ * argument skipping to avoid collecting inline patterns/scripts
349
+ * as path candidates. For all other commands, collects all
350
+ * arguments generically.
351
+ */
352
+ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
353
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return;
354
+
355
+ // Extract arguments from `command` nodes.
356
+ if (node.type === "command") {
357
+ const commandName = extractCommandName(node);
358
+ const patternConfig = commandName
359
+ ? PATTERN_FIRST_COMMANDS.get(commandName)
360
+ : undefined;
361
+
362
+ if (patternConfig) {
363
+ collectPatternCommandTokens(node, tokens, patternConfig);
364
+ return;
365
+ }
366
+
367
+ // Generic extraction: collect all arguments (skip command name).
368
+ let seenCommandName = false;
369
+ for (let i = 0; i < node.childCount; i++) {
370
+ const child = node.child(i);
371
+ if (!child) continue;
372
+
373
+ if (child.type === "command_name") {
374
+ seenCommandName = true;
375
+ continue;
376
+ }
377
+ // Skip variable_assignment nodes (FOO=/bar)
378
+ if (child.type === "variable_assignment") continue;
379
+
380
+ // If there was no explicit command_name node, the first word-like
381
+ // child is the command name itself — skip it.
382
+ if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
383
+ seenCommandName = true;
384
+ continue;
385
+ }
386
+
387
+ // Argument nodes: resolve their text and collect.
388
+ if (ARG_NODE_TYPES.has(child.type)) {
389
+ tokens.push(resolveNodeText(child));
390
+ continue;
391
+ }
392
+
393
+ // Recurse into other children (e.g. command_substitution nested in args)
394
+ collectPathCandidateTokens(child, tokens);
395
+ }
396
+ return;
397
+ }
398
+
399
+ // Extract redirect destinations from `file_redirect` nodes.
400
+ if (node.type === "file_redirect") {
401
+ for (let i = 0; i < node.childCount; i++) {
402
+ const child = node.child(i);
403
+ if (!child) continue;
404
+ if (
405
+ child.type === "word" ||
406
+ child.type === "concatenation" ||
407
+ child.type === "string" ||
408
+ child.type === "raw_string"
409
+ ) {
410
+ tokens.push(resolveNodeText(child));
411
+ }
412
+ }
413
+ return;
414
+ }
415
+
416
+ // For all other node types, recurse into children.
417
+ for (let i = 0; i < node.childCount; i++) {
418
+ const child = node.child(i);
419
+ if (!child) continue;
420
+ collectPathCandidateTokens(child, tokens);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * URL pattern to skip tokens that look like URLs rather than paths.
426
+ */
427
+ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
428
+
429
+ /**
430
+ * Regex metacharacter sequences that are never found in real filesystem paths.
431
+ * If a token contains any of these, it is almost certainly a regex pattern
432
+ * (e.g. a grep argument) rather than a path.
433
+ */
434
+ const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
435
+
436
+ /**
437
+ * Determines whether a token looks like a path candidate worth resolving.
438
+ * Returns the raw token string if it's a candidate, or null to skip.
439
+ */
440
+ function classifyTokenAsPathCandidate(token: string): string | null {
441
+ // Skip empty tokens
442
+ if (!token) return null;
443
+
444
+ // Skip flags
445
+ if (token.startsWith("-")) return null;
446
+
447
+ // Skip env assignments (FOO=/bar)
448
+ const eqIndex = token.indexOf("=");
449
+ const slashIndex = token.indexOf("/");
450
+ if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
451
+ return null;
452
+ }
453
+
454
+ // Skip URLs
455
+ if (URL_PATTERN.test(token)) return null;
456
+
457
+ // Skip @scope/package patterns
458
+ if (token.startsWith("@") && !token.startsWith("@/")) return null;
459
+
460
+ // Skip bare-slash tokens (// JS comments, lone /, etc.) — they resolve to root
461
+ // and are never meaningful path arguments in practice.
462
+ if (/^\/+$/.test(token)) return null;
463
+
464
+ // Skip tokens that contain regex metacharacter sequences — these are almost
465
+ // certainly grep/sed/awk patterns, not filesystem paths.
466
+ // Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
467
+ if (REGEX_METACHAR_PATTERN.test(token)) return null;
468
+
469
+ // Must look like a path: starts with /, ~/, or contains ..
470
+ if (token.startsWith("/")) return token;
471
+ if (token.startsWith("~/")) return token;
472
+ if (token.includes("..")) return token;
473
+
474
+ return null;
475
+ }
476
+
477
+ /**
478
+ * Extracts paths from a bash command string that resolve outside CWD.
479
+ * Uses tree-sitter-bash to parse the command into a full AST, then walks
480
+ * command argument and redirect-destination nodes. Heredoc bodies, comments,
481
+ * and other non-argument content are skipped, eliminating false positives.
482
+ */
483
+ export async function extractExternalPathsFromBashCommand(
484
+ command: string,
485
+ cwd: string,
486
+ ): Promise<string[]> {
487
+ const parser = await getParser();
488
+ const tree = parser.parse(command);
489
+ if (!tree) return [];
490
+
491
+ const tokens: string[] = [];
492
+ try {
493
+ collectPathCandidateTokens(tree.rootNode, tokens);
494
+ } finally {
495
+ tree.delete();
496
+ }
497
+
498
+ const seen = new Set<string>();
499
+ const externalPaths: string[] = [];
500
+
501
+ for (const token of tokens) {
502
+ const candidate = classifyTokenAsPathCandidate(token);
503
+ if (!candidate) continue;
504
+
505
+ const normalized = normalizePathForComparison(candidate, cwd);
506
+ if (!normalized) continue;
507
+
508
+ if (
509
+ isPathOutsideWorkingDirectory(candidate, cwd) &&
510
+ !seen.has(normalized)
511
+ ) {
512
+ seen.add(normalized);
513
+ externalPaths.push(normalized);
514
+ }
515
+ }
516
+
517
+ return externalPaths;
518
+ }
@@ -0,0 +1,54 @@
1
+ export function formatExternalDirectoryHardStopHint(): string {
2
+ return "Hard stop: this external directory permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.";
3
+ }
4
+
5
+ export function formatExternalDirectoryAskPrompt(
6
+ toolName: string,
7
+ pathValue: string,
8
+ cwd: string,
9
+ agentName?: string,
10
+ ): string {
11
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
12
+ return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
13
+ }
14
+
15
+ export function formatExternalDirectoryDenyReason(
16
+ toolName: string,
17
+ pathValue: string,
18
+ cwd: string,
19
+ agentName?: string,
20
+ ): string {
21
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
22
+ return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
23
+ }
24
+
25
+ export function formatExternalDirectoryUserDeniedReason(
26
+ toolName: string,
27
+ pathValue: string,
28
+ denialReason?: string,
29
+ ): string {
30
+ const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
31
+ return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
32
+ }
33
+
34
+ export function formatBashExternalDirectoryAskPrompt(
35
+ command: string,
36
+ externalPaths: string[],
37
+ cwd: string,
38
+ agentName?: string,
39
+ ): string {
40
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
41
+ const pathList = externalPaths.join(", ");
42
+ return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
43
+ }
44
+
45
+ export function formatBashExternalDirectoryDenyReason(
46
+ command: string,
47
+ externalPaths: string[],
48
+ cwd: string,
49
+ agentName?: string,
50
+ ): string {
51
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
52
+ const pathList = externalPaths.join(", ");
53
+ return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
54
+ }