@gotgenes/pi-permission-system 8.2.0 → 8.3.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 +23 -0
- package/package.json +1 -1
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/config-loader.ts +53 -46
- package/src/handlers/gates/bash-path-extractor.ts +135 -169
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/permission-gate-handler.ts +3 -0
- package/src/index.ts +13 -1
- package/src/permission-prompts.ts +5 -1
- package/src/service.ts +21 -1
- package/src/tool-input-formatter-registry.ts +57 -0
- package/src/tool-preview-formatter.ts +18 -1
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/config-loader.test.ts +82 -0
- package/test/handlers/before-agent-start.test.ts +2 -20
- package/test/handlers/external-directory-integration.test.ts +43 -81
- package/test/handlers/external-directory-session-dedup.test.ts +2 -29
- package/test/handlers/gates/bash-path.test.ts +5 -26
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/path.test.ts +3 -12
- package/test/handlers/gates/runner.test.ts +78 -91
- package/test/handlers/input-events.test.ts +42 -95
- package/test/handlers/input.test.ts +3 -71
- package/test/handlers/lifecycle.test.ts +3 -20
- package/test/handlers/tool-call-events.test.ts +30 -127
- package/test/handlers/tool-call.test.ts +21 -110
- package/test/helpers/gate-fixtures.ts +105 -0
- package/test/helpers/handler-fixtures.ts +141 -0
- package/test/helpers/manager-harness.ts +51 -0
- package/test/permission-prompts.test.ts +53 -7
- package/test/permission-session.test.ts +1 -19
- package/test/permission-system.test.ts +4 -40
- package/test/service.test.ts +52 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-preview-formatter.test.ts +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
## [8.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.1...pi-permission-system-v8.3.0) (2026-06-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add built-in MCP input summarizer ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2d47e36](https://github.com/gotgenes/pi-packages/commit/2d47e360b475c72c76026ea5ea4ebf6446b58c3e))
|
|
14
|
+
* add ToolInputFormatterRegistry ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([c2c2b3d](https://github.com/gotgenes/pi-packages/commit/c2c2b3d64664b03cf6715e630e0bb59c4d1b650c))
|
|
15
|
+
* consult custom formatter registry in ToolPreviewFormatter ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([9a0d756](https://github.com/gotgenes/pi-packages/commit/9a0d75600f7aa364c06bee7c0419c64d9a5325e9))
|
|
16
|
+
* expose registerToolInputFormatter on PermissionsService ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2287484](https://github.com/gotgenes/pi-packages/commit/2287484e24392fffac37962e41ad985446e75d2d))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* add authoring guide for tool input formatters ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([6d154a1](https://github.com/gotgenes/pi-packages/commit/6d154a14a7a1f26ded4f1d77d50b52d200b70a27))
|
|
22
|
+
* document tool input formatter seam ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2fc9ff1](https://github.com/gotgenes/pi-packages/commit/2fc9ff1df97341b8825ef13c99a3ffd651dcd8e0))
|
|
23
|
+
|
|
24
|
+
## [8.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.0...pi-permission-system-v8.2.1) (2026-05-31)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* remove stale PermissionGateHandler import in tool-call.test.ts ([#288](https://github.com/gotgenes/pi-packages/issues/288)) ([67259f6](https://github.com/gotgenes/pi-packages/commit/67259f666938e15473016edfeafcb718abe304f7))
|
|
30
|
+
|
|
8
31
|
## [8.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.1.0...pi-permission-system-v8.2.0) (2026-05-31)
|
|
9
32
|
|
|
10
33
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in tool input formatters registered through the public seam at startup.
|
|
3
|
+
*
|
|
4
|
+
* Each formatter here dogfoods `ToolInputFormatterRegistry.register` — it goes
|
|
5
|
+
* through exactly the same path a third-party extension would use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { toRecord } from "./common";
|
|
9
|
+
import type {
|
|
10
|
+
ToolInputFormatter,
|
|
11
|
+
ToolInputFormatterRegistry,
|
|
12
|
+
} from "./tool-input-formatter-registry";
|
|
13
|
+
import { truncateInlineText } from "./tool-input-preview";
|
|
14
|
+
|
|
15
|
+
/** Maximum total length of the generated argument summary (before "with " prefix). */
|
|
16
|
+
const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
|
|
17
|
+
|
|
18
|
+
/** Maximum length of a single string argument value (before quoting). */
|
|
19
|
+
const MCP_ARG_VALUE_MAX_LENGTH = 60;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a single MCP argument value as a compact, readable fragment.
|
|
23
|
+
*
|
|
24
|
+
* - Strings: quoted and truncated.
|
|
25
|
+
* - Numbers / booleans: plain string conversion.
|
|
26
|
+
* - Arrays: `[N items]`.
|
|
27
|
+
* - Objects: `{…}`.
|
|
28
|
+
* - Everything else: plain string conversion.
|
|
29
|
+
*/
|
|
30
|
+
function renderArgValue(value: unknown): string {
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
return `"${truncateInlineText(value, MCP_ARG_VALUE_MAX_LENGTH)}"`;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
35
|
+
return String(value);
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return `[${value.length} items]`;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === "object" && value !== null) {
|
|
41
|
+
return "{…}";
|
|
42
|
+
}
|
|
43
|
+
return String(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format an MCP tool call's `arguments` payload as a human-readable summary.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` when `arguments` is absent or empty — the MCP ask-prompt
|
|
50
|
+
* is then left unchanged (no suffix appended).
|
|
51
|
+
*
|
|
52
|
+
* Intended to be registered as the `"mcp"` formatter via
|
|
53
|
+
* `registerBuiltinToolInputFormatters`.
|
|
54
|
+
*/
|
|
55
|
+
export const formatMcpInputForPrompt: ToolInputFormatter = (
|
|
56
|
+
input: Record<string, unknown>,
|
|
57
|
+
): string | undefined => {
|
|
58
|
+
const args = toRecord(input.arguments);
|
|
59
|
+
const entries = Object.entries(args);
|
|
60
|
+
if (entries.length === 0) return undefined;
|
|
61
|
+
|
|
62
|
+
const parts = entries.map(
|
|
63
|
+
([key, value]) => `${key}: ${renderArgValue(value)}`,
|
|
64
|
+
);
|
|
65
|
+
const summary = truncateInlineText(
|
|
66
|
+
parts.join(", "),
|
|
67
|
+
MCP_ARGS_SUMMARY_MAX_LENGTH,
|
|
68
|
+
);
|
|
69
|
+
return `with ${summary}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register all built-in tool input formatters into `registry`.
|
|
74
|
+
*
|
|
75
|
+
* Called once from the extension factory (`index.ts`) immediately after the
|
|
76
|
+
* registry is constructed, before any third-party registration can occur.
|
|
77
|
+
*/
|
|
78
|
+
export function registerBuiltinToolInputFormatters(
|
|
79
|
+
registry: ToolInputFormatterRegistry,
|
|
80
|
+
): void {
|
|
81
|
+
registry.register("mcp", formatMcpInputForPrompt);
|
|
82
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -33,74 +33,81 @@ export interface UnifiedConfigLoadResult {
|
|
|
33
33
|
|
|
34
34
|
export function stripJsonComments(input: string): string {
|
|
35
35
|
let output = "";
|
|
36
|
-
let
|
|
37
|
-
|
|
38
|
-
let escaping = false;
|
|
39
|
-
let inLineComment = false;
|
|
40
|
-
let inBlockComment = false;
|
|
41
|
-
|
|
42
|
-
for (let i = 0; i < input.length; i++) {
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < input.length) {
|
|
43
38
|
const char = input[i];
|
|
44
|
-
const next = input[i + 1]
|
|
45
|
-
|
|
46
|
-
if (inLineComment) {
|
|
47
|
-
if (char === "\n") {
|
|
48
|
-
inLineComment = false;
|
|
49
|
-
output += char;
|
|
50
|
-
}
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
39
|
+
const next = input[i + 1] ?? "";
|
|
53
40
|
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
41
|
+
if (char === "/" && next === "/") {
|
|
42
|
+
const seg = consumeLineComment(input, i);
|
|
43
|
+
output += seg.output;
|
|
44
|
+
i = seg.nextIndex;
|
|
59
45
|
continue;
|
|
60
46
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
i
|
|
47
|
+
if (char === "/" && next === "*") {
|
|
48
|
+
const seg = consumeBlockComment(input, i);
|
|
49
|
+
output += seg.output;
|
|
50
|
+
i = seg.nextIndex;
|
|
65
51
|
continue;
|
|
66
52
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
i
|
|
53
|
+
if (char === '"' || char === "'") {
|
|
54
|
+
const seg = consumeString(input, i);
|
|
55
|
+
output += seg.output;
|
|
56
|
+
i = seg.nextIndex;
|
|
71
57
|
continue;
|
|
72
58
|
}
|
|
73
59
|
|
|
74
60
|
output += char;
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
}
|
|
75
65
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
66
|
+
/** A consumed run of source: the text to emit and the index to resume scanning. */
|
|
67
|
+
interface ScanSegment {
|
|
68
|
+
output: string;
|
|
69
|
+
nextIndex: number;
|
|
70
|
+
}
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
/** Consume a `//` line comment starting at `start`; drop the body, keep the newline. */
|
|
73
|
+
function consumeLineComment(input: string, start: number): ScanSegment {
|
|
74
|
+
const newlineIndex = input.indexOf("\n", start);
|
|
75
|
+
if (newlineIndex === -1) return { output: "", nextIndex: input.length };
|
|
76
|
+
return { output: "\n", nextIndex: newlineIndex + 1 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Consume a block comment starting at `start`; drop it entirely. */
|
|
80
|
+
function consumeBlockComment(input: string, start: number): ScanSegment {
|
|
81
|
+
const closeIndex = input.indexOf("*/", start + 2);
|
|
82
|
+
if (closeIndex === -1) return { output: "", nextIndex: input.length };
|
|
83
|
+
return { output: "", nextIndex: closeIndex + 2 };
|
|
84
|
+
}
|
|
86
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Consume a string literal starting at the opening quote at `start`.
|
|
88
|
+
* Honors backslash escapes so an escaped quote does not close the literal.
|
|
89
|
+
* Emits the opening quote, body, and closing quote verbatim.
|
|
90
|
+
*/
|
|
91
|
+
function consumeString(input: string, start: number): ScanSegment {
|
|
92
|
+
const quote = input[start];
|
|
93
|
+
let output = quote;
|
|
94
|
+
let i = start + 1;
|
|
95
|
+
let escaping = false;
|
|
96
|
+
while (i < input.length) {
|
|
97
|
+
const char = input[i];
|
|
98
|
+
output += char;
|
|
99
|
+
i++;
|
|
87
100
|
if (escaping) {
|
|
88
101
|
escaping = false;
|
|
89
102
|
continue;
|
|
90
103
|
}
|
|
91
|
-
|
|
92
104
|
if (char === "\\") {
|
|
93
105
|
escaping = true;
|
|
94
106
|
continue;
|
|
95
107
|
}
|
|
96
|
-
|
|
97
|
-
if (char === stringQuote) {
|
|
98
|
-
inString = false;
|
|
99
|
-
stringQuote = "";
|
|
100
|
-
}
|
|
108
|
+
if (char === quote) break;
|
|
101
109
|
}
|
|
102
|
-
|
|
103
|
-
return output;
|
|
110
|
+
return { output, nextIndex: i };
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
classifyTokenAsPathCandidate,
|
|
6
|
+
classifyTokenAsRuleCandidate,
|
|
7
|
+
} from "#src/handlers/gates/bash-token-classification";
|
|
4
8
|
import {
|
|
5
9
|
isPathWithinDirectory,
|
|
6
10
|
isSafeSystemPath,
|
|
@@ -237,6 +241,47 @@ function extractCommandName(node: TSNode): string | undefined {
|
|
|
237
241
|
return undefined;
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Describes what the walker should do when it encounters a flag word inside
|
|
246
|
+
* a pattern-first command. Using a discriminated union lets the `switch` in
|
|
247
|
+
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
|
|
248
|
+
* assertion (which would trigger the Biome/ESLint assertion conflict).
|
|
249
|
+
*/
|
|
250
|
+
type PatternCommandFlagDirective =
|
|
251
|
+
| { kind: "end-of-flags" }
|
|
252
|
+
| { kind: "regular-flag" }
|
|
253
|
+
| {
|
|
254
|
+
kind: "consume-arg";
|
|
255
|
+
nextArgAction: "skip" | "extract";
|
|
256
|
+
setsExplicitScript: boolean;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Classify a flag word from a pattern-first command into a directive that
|
|
261
|
+
* tells the walker how to handle the flag and its following argument.
|
|
262
|
+
*/
|
|
263
|
+
function classifyPatternCommandFlag(
|
|
264
|
+
text: string,
|
|
265
|
+
config: PatternCommandConfig,
|
|
266
|
+
): PatternCommandFlagDirective {
|
|
267
|
+
if (text === "--") return { kind: "end-of-flags" };
|
|
268
|
+
if (config.argConsumingFlags.has(text)) {
|
|
269
|
+
return {
|
|
270
|
+
kind: "consume-arg",
|
|
271
|
+
nextArgAction: "skip",
|
|
272
|
+
setsExplicitScript: text === "-e" || text === "-f",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (config.fileConsumingFlags.has(text)) {
|
|
276
|
+
return {
|
|
277
|
+
kind: "consume-arg",
|
|
278
|
+
nextArgAction: "extract",
|
|
279
|
+
setsExplicitScript: true,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { kind: "regular-flag" };
|
|
283
|
+
}
|
|
284
|
+
|
|
240
285
|
/**
|
|
241
286
|
* Collect path-candidate tokens from a command known to have
|
|
242
287
|
* pattern/script arguments in leading positional slots.
|
|
@@ -254,14 +299,14 @@ function extractCommandName(node: TSNode): string | undefined {
|
|
|
254
299
|
*/
|
|
255
300
|
function collectPatternCommandTokens(
|
|
256
301
|
node: TSNode,
|
|
257
|
-
tokens: string[],
|
|
258
302
|
config: PatternCommandConfig,
|
|
259
|
-
):
|
|
303
|
+
): string[] {
|
|
260
304
|
const patternPositionals = config.patternPositionals ?? 1;
|
|
261
305
|
let hasExplicitScript = false;
|
|
262
306
|
let positionalsSeen = 0;
|
|
263
307
|
let nextArgAction: "skip" | "extract" | null = null;
|
|
264
308
|
let pastEndOfFlags = false;
|
|
309
|
+
const tokens: string[] = [];
|
|
265
310
|
|
|
266
311
|
for (let i = 0; i < node.childCount; i++) {
|
|
267
312
|
const child = node.child(i);
|
|
@@ -274,7 +319,7 @@ function collectPatternCommandTokens(
|
|
|
274
319
|
// Only process argument-like nodes; recurse into others
|
|
275
320
|
// (e.g. command_substitution) for nested commands.
|
|
276
321
|
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
277
|
-
collectPathCandidateTokens(child
|
|
322
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
278
323
|
continue;
|
|
279
324
|
}
|
|
280
325
|
|
|
@@ -298,23 +343,18 @@ function collectPatternCommandTokens(
|
|
|
298
343
|
text.startsWith("-") &&
|
|
299
344
|
text.length > 1
|
|
300
345
|
) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
hasExplicitScript = true;
|
|
309
|
-
|
|
310
|
-
|
|
346
|
+
const directive = classifyPatternCommandFlag(text, config);
|
|
347
|
+
switch (directive.kind) {
|
|
348
|
+
case "end-of-flags":
|
|
349
|
+
pastEndOfFlags = true;
|
|
350
|
+
break;
|
|
351
|
+
case "consume-arg":
|
|
352
|
+
nextArgAction = directive.nextArgAction;
|
|
353
|
+
if (directive.setsExplicitScript) hasExplicitScript = true;
|
|
354
|
+
break;
|
|
355
|
+
case "regular-flag":
|
|
356
|
+
break;
|
|
311
357
|
}
|
|
312
|
-
if (config.fileConsumingFlags.has(text)) {
|
|
313
|
-
nextArgAction = "extract";
|
|
314
|
-
hasExplicitScript = true;
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
// Regular flag — skip it.
|
|
318
358
|
continue;
|
|
319
359
|
}
|
|
320
360
|
|
|
@@ -327,181 +367,107 @@ function collectPatternCommandTokens(
|
|
|
327
367
|
// File argument — collect as path candidate.
|
|
328
368
|
tokens.push(text);
|
|
329
369
|
}
|
|
370
|
+
|
|
371
|
+
return tokens;
|
|
330
372
|
}
|
|
331
373
|
|
|
332
374
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
337
|
-
*
|
|
338
|
-
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
339
|
-
* argument skipping to avoid collecting inline patterns/scripts
|
|
340
|
-
* as path candidates. For all other commands, collects all
|
|
341
|
-
* arguments generically.
|
|
375
|
+
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
|
376
|
+
* skipping the command name and variable assignments.
|
|
342
377
|
*/
|
|
343
|
-
function
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// Extract arguments from `command` nodes.
|
|
347
|
-
if (node.type === "command") {
|
|
348
|
-
const commandName = extractCommandName(node);
|
|
349
|
-
const patternConfig = commandName
|
|
350
|
-
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
351
|
-
: undefined;
|
|
352
|
-
|
|
353
|
-
if (patternConfig) {
|
|
354
|
-
collectPatternCommandTokens(node, tokens, patternConfig);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Generic extraction: collect all arguments (skip command name).
|
|
359
|
-
let seenCommandName = false;
|
|
360
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
361
|
-
const child = node.child(i);
|
|
362
|
-
if (!child) continue;
|
|
378
|
+
function collectGenericCommandTokens(node: TSNode): string[] {
|
|
379
|
+
const tokens: string[] = [];
|
|
380
|
+
let seenCommandName = false;
|
|
363
381
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
// Skip variable_assignment nodes (FOO=/bar)
|
|
369
|
-
if (child.type === "variable_assignment") continue;
|
|
370
|
-
|
|
371
|
-
// If there was no explicit command_name node, the first word-like
|
|
372
|
-
// child is the command name itself — skip it.
|
|
373
|
-
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
374
|
-
seenCommandName = true;
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
382
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
383
|
+
const child = node.child(i);
|
|
384
|
+
if (!child) continue;
|
|
377
385
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
386
|
+
if (child.type === "command_name") {
|
|
387
|
+
seenCommandName = true;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// Skip variable_assignment nodes (FOO=/bar)
|
|
391
|
+
if (child.type === "variable_assignment") continue;
|
|
383
392
|
|
|
384
|
-
|
|
385
|
-
|
|
393
|
+
// If there was no explicit command_name node, the first word-like
|
|
394
|
+
// child is the command name itself — skip it.
|
|
395
|
+
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
396
|
+
seenCommandName = true;
|
|
397
|
+
continue;
|
|
386
398
|
}
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
399
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (!child) continue;
|
|
395
|
-
if (
|
|
396
|
-
child.type === "word" ||
|
|
397
|
-
child.type === "concatenation" ||
|
|
398
|
-
child.type === "string" ||
|
|
399
|
-
child.type === "raw_string"
|
|
400
|
-
) {
|
|
401
|
-
tokens.push(resolveNodeText(child));
|
|
402
|
-
}
|
|
400
|
+
// Argument nodes: resolve their text and collect.
|
|
401
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
402
|
+
tokens.push(resolveNodeText(child));
|
|
403
|
+
continue;
|
|
403
404
|
}
|
|
404
|
-
|
|
405
|
+
|
|
406
|
+
// Recurse into other children (e.g. command_substitution nested in args)
|
|
407
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
405
408
|
}
|
|
406
409
|
|
|
407
|
-
|
|
410
|
+
return tokens;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Collect redirect-destination tokens from a `file_redirect` node.
|
|
415
|
+
*/
|
|
416
|
+
function collectRedirectTokens(node: TSNode): string[] {
|
|
417
|
+
const tokens: string[] = [];
|
|
408
418
|
for (let i = 0; i < node.childCount; i++) {
|
|
409
419
|
const child = node.child(i);
|
|
410
420
|
if (!child) continue;
|
|
411
|
-
|
|
421
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
422
|
+
tokens.push(resolveNodeText(child));
|
|
423
|
+
}
|
|
412
424
|
}
|
|
425
|
+
return tokens;
|
|
413
426
|
}
|
|
414
427
|
|
|
415
428
|
/**
|
|
416
|
-
*
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Regex metacharacter sequences that are never found in real filesystem paths.
|
|
422
|
-
* If a token contains any of these, it is almost certainly a regex pattern
|
|
423
|
-
* (e.g. a grep argument) rather than a path.
|
|
429
|
+
* Select the collection strategy for a `command` node: pattern-first
|
|
430
|
+
* commands use `collectPatternCommandTokens`; all others use
|
|
431
|
+
* `collectGenericCommandTokens`.
|
|
424
432
|
*/
|
|
425
|
-
|
|
433
|
+
function collectCommandTokens(node: TSNode): string[] {
|
|
434
|
+
const commandName = extractCommandName(node);
|
|
435
|
+
const config = commandName
|
|
436
|
+
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
437
|
+
: undefined;
|
|
438
|
+
return config
|
|
439
|
+
? collectPatternCommandTokens(node, config)
|
|
440
|
+
: collectGenericCommandTokens(node);
|
|
441
|
+
}
|
|
426
442
|
|
|
427
443
|
/**
|
|
428
|
-
*
|
|
444
|
+
* Recursively visit the AST and collect resolved text of nodes that
|
|
445
|
+
* represent command arguments or redirect destinations.
|
|
429
446
|
*
|
|
430
|
-
*
|
|
431
|
-
* env assignments, URLs, @scope/package, bare-slash, regex metacharacters),
|
|
432
|
-
* but also accepts:
|
|
433
|
-
* - Tokens starting with `.` (dot-files: `.env`, `./src`)
|
|
434
|
-
* - Tokens containing `/` (relative paths: `src/foo.ts`)
|
|
447
|
+
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
435
448
|
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
449
|
+
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
450
|
+
* argument skipping to avoid collecting inline patterns/scripts
|
|
451
|
+
* as path candidates. For all other commands, collects all
|
|
452
|
+
* arguments generically.
|
|
438
453
|
*/
|
|
439
|
-
function
|
|
440
|
-
if (
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
const eqIndex = token.indexOf("=");
|
|
444
|
-
const slashIndex = token.indexOf("/");
|
|
445
|
-
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (URL_PATTERN.test(token)) return null;
|
|
450
|
-
if (token.startsWith("@") && !token.startsWith("@/")) return null;
|
|
451
|
-
if (/^\/+$/.test(token)) return null;
|
|
452
|
-
if (REGEX_METACHAR_PATTERN.test(token)) return null;
|
|
454
|
+
function collectPathCandidateTokens(node: TSNode): string[] {
|
|
455
|
+
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
456
|
+
if (node.type === "command") return collectCommandTokens(node);
|
|
457
|
+
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
453
458
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (token.startsWith("~/")) return token;
|
|
459
|
-
if (token.includes("..")) return token;
|
|
460
|
-
|
|
461
|
-
return null;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Determines whether a token looks like a path candidate worth resolving.
|
|
466
|
-
* Returns the raw token string if it's a candidate, or null to skip.
|
|
467
|
-
*/
|
|
468
|
-
function classifyTokenAsPathCandidate(token: string): string | null {
|
|
469
|
-
// Skip empty tokens
|
|
470
|
-
if (!token) return null;
|
|
471
|
-
|
|
472
|
-
// Skip flags
|
|
473
|
-
if (token.startsWith("-")) return null;
|
|
474
|
-
|
|
475
|
-
// Skip env assignments (FOO=/bar)
|
|
476
|
-
const eqIndex = token.indexOf("=");
|
|
477
|
-
const slashIndex = token.indexOf("/");
|
|
478
|
-
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
|
|
479
|
-
return null;
|
|
459
|
+
const tokens: string[] = [];
|
|
460
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
461
|
+
const child = node.child(i);
|
|
462
|
+
if (child) tokens.push(...collectPathCandidateTokens(child));
|
|
480
463
|
}
|
|
481
|
-
|
|
482
|
-
// Skip URLs
|
|
483
|
-
if (URL_PATTERN.test(token)) return null;
|
|
484
|
-
|
|
485
|
-
// Skip @scope/package patterns
|
|
486
|
-
if (token.startsWith("@") && !token.startsWith("@/")) return null;
|
|
487
|
-
|
|
488
|
-
// Skip bare-slash tokens (// JS comments, lone /, etc.) — they resolve to root
|
|
489
|
-
// and are never meaningful path arguments in practice.
|
|
490
|
-
if (/^\/+$/.test(token)) return null;
|
|
491
|
-
|
|
492
|
-
// Skip tokens that contain regex metacharacter sequences — these are almost
|
|
493
|
-
// certainly grep/sed/awk patterns, not filesystem paths.
|
|
494
|
-
// Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
|
|
495
|
-
if (REGEX_METACHAR_PATTERN.test(token)) return null;
|
|
496
|
-
|
|
497
|
-
// Must look like a path: starts with /, ~/, or contains ..
|
|
498
|
-
if (token.startsWith("/")) return token;
|
|
499
|
-
if (token.startsWith("~/")) return token;
|
|
500
|
-
if (token.includes("..")) return token;
|
|
501
|
-
|
|
502
|
-
return null;
|
|
464
|
+
return tokens;
|
|
503
465
|
}
|
|
504
466
|
|
|
467
|
+
// Token classification is delegated to bash-token-classification.ts,
|
|
468
|
+
// which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
|
|
469
|
+
// with a shared rejectNonPathToken predicate eliminating the prior clone.
|
|
470
|
+
|
|
505
471
|
// ── Leading cd detection ───────────────────────────────────────────────────
|
|
506
472
|
|
|
507
473
|
/**
|
|
@@ -596,10 +562,10 @@ export async function extractExternalPathsFromBashCommand(
|
|
|
596
562
|
if (!tree) return [];
|
|
597
563
|
|
|
598
564
|
let cdTarget: string | undefined;
|
|
599
|
-
|
|
565
|
+
let tokens: string[] = [];
|
|
600
566
|
try {
|
|
601
567
|
cdTarget = extractLeadingCdTarget(tree.rootNode);
|
|
602
|
-
collectPathCandidateTokens(tree.rootNode
|
|
568
|
+
tokens = collectPathCandidateTokens(tree.rootNode);
|
|
603
569
|
} finally {
|
|
604
570
|
tree.delete();
|
|
605
571
|
}
|
|
@@ -647,9 +613,9 @@ export async function extractTokensForPathRules(
|
|
|
647
613
|
const tree = parser.parse(command);
|
|
648
614
|
if (!tree) return [];
|
|
649
615
|
|
|
650
|
-
|
|
616
|
+
let tokens: string[] = [];
|
|
651
617
|
try {
|
|
652
|
-
collectPathCandidateTokens(tree.rootNode
|
|
618
|
+
tokens = collectPathCandidateTokens(tree.rootNode);
|
|
653
619
|
} finally {
|
|
654
620
|
tree.delete();
|
|
655
621
|
}
|