@gotgenes/pi-permission-system 16.0.1 → 16.0.2

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/bash/command-enumeration.ts +154 -0
  4. package/src/access-intent/bash/cwd-projection.ts +493 -0
  5. package/src/access-intent/bash/node-text.ts +75 -0
  6. package/src/access-intent/bash/parser.ts +42 -0
  7. package/src/access-intent/bash/program.ts +102 -0
  8. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  9. package/src/access-intent/bash/token-collection.ts +351 -0
  10. package/src/handlers/gates/bash-command.ts +1 -1
  11. package/src/handlers/gates/bash-external-directory.ts +3 -3
  12. package/src/handlers/gates/bash-path-extractor.ts +2 -2
  13. package/src/handlers/gates/bash-path.ts +2 -2
  14. package/src/handlers/gates/external-directory.ts +0 -2
  15. package/src/handlers/gates/path.ts +1 -3
  16. package/src/handlers/gates/skill-read.ts +0 -4
  17. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  18. package/src/handlers/gates/tool.ts +1 -1
  19. package/src/handlers/gates/types.ts +1 -1
  20. package/test/access-intent/bash/node-text.test.ts +147 -0
  21. package/test/access-intent/bash/parser.test.ts +19 -0
  22. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +96 -73
  23. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  24. package/test/access-intent/bash/token-collection.test.ts +300 -0
  25. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  26. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  27. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  28. package/test/handlers/gates/bash-path.test.ts +2 -2
  29. package/test/handlers/gates/external-directory.test.ts +0 -5
  30. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
  31. package/src/handlers/gates/bash-program.ts +0 -1143
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ 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.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.1...pi-permission-system-v16.0.2) (2026-06-25)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-permission-system:** record parser/node-text extraction in architecture and skill ([#473](https://github.com/gotgenes/pi-packages/issues/473)) ([7626425](https://github.com/gotgenes/pi-packages/commit/7626425f587dd62ab54f39390841a6ca152b32e1))
14
+
8
15
  ## [16.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.0...pi-permission-system-v16.0.1) (2026-06-21)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.0.1",
3
+ "version": "16.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,154 @@
1
+ import type { TSNode } from "#src/access-intent/bash/parser";
2
+ import type { BashCommandContext } from "#src/types";
3
+
4
+ // ── Command type ─────────────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * One command-pattern unit of a parsed bash program.
8
+ *
9
+ * Minimal by design — `text` is the simple-command (or whole compound
10
+ * statement) string matched against the bash rules.
11
+ * The type is the stable extension point: #306 adds an execution `context`,
12
+ * #307 adds per-command path candidates and an effective working directory.
13
+ */
14
+ export interface BashCommand {
15
+ readonly text: string;
16
+ /**
17
+ * Execution context for a nested command (substitution or subshell); absent
18
+ * for a current-shell (top-level) command.
19
+ */
20
+ readonly context?: BashCommandContext;
21
+ }
22
+
23
+ // ── Command enumeration ──────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Container node types descended into when enumerating command units.
27
+ */
28
+ const COMMAND_ENUM_DESCEND = new Set([
29
+ "program",
30
+ "list",
31
+ "pipeline",
32
+ "redirected_statement",
33
+ ]);
34
+
35
+ /**
36
+ * Named node types skipped during command enumeration: redirect targets,
37
+ * comments, and heredoc bodies — none is a command to evaluate.
38
+ * Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
39
+ * delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
40
+ * listed here.
41
+ */
42
+ const COMMAND_ENUM_SKIP = new Set([
43
+ "file_redirect",
44
+ "heredoc_redirect",
45
+ "herestring_redirect",
46
+ "comment",
47
+ "heredoc_body",
48
+ "heredoc_end",
49
+ ]);
50
+
51
+ /**
52
+ * Nested execution contexts whose interior commands really execute and must be
53
+ * evaluated too: command substitution (`$(…)`, backticks) and process
54
+ * substitution (`<(…)`/`>(…)`).
55
+ * Subshells (`( … )`) are handled separately because they are also emitted
56
+ * whole.
57
+ */
58
+ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
59
+ ["command_substitution", "command_substitution"],
60
+ ["process_substitution", "process_substitution"],
61
+ ]);
62
+
63
+ /**
64
+ * Enumerate the command units of a bash program, in source order.
65
+ *
66
+ * Descends container nodes (`program`, `list`, `pipeline`,
67
+ * `redirected_statement`) and emits each `command` node whole.
68
+ * Additionally descends into the three nested execution contexts — command
69
+ * substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and
70
+ * subshells (`( … )`) — emitting each inner command as its own unit *in
71
+ * addition to* the enclosing command, since those inner commands really execute
72
+ * (#306).
73
+ * Control-flow bodies and `{ … }` brace groups are emitted whole without
74
+ * descending (deferred).
75
+ *
76
+ * The enclosing command/subshell is always still emitted whole, so adding the
77
+ * nested units can only ever produce a more-restrictive decision, never weaker.
78
+ */
79
+ export function collectCommands(node: TSNode): BashCommand[] {
80
+ const out: BashCommand[] = [];
81
+ collectCommandsInto(node, undefined, out);
82
+ return out;
83
+ }
84
+
85
+ function collectCommandsInto(
86
+ node: TSNode,
87
+ context: BashCommandContext | undefined,
88
+ out: BashCommand[],
89
+ ): void {
90
+ // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
91
+ // carry no command.
92
+ if (!node.isNamed) return;
93
+ if (COMMAND_ENUM_SKIP.has(node.type)) return;
94
+
95
+ if (node.type === "command") {
96
+ out.push(makeUnit(node.text, context));
97
+ // A command's text already contains any substitution; descend its subtree
98
+ // to ALSO emit the inner commands of command/process substitutions.
99
+ collectSubstitutionCommands(node, out);
100
+ return;
101
+ }
102
+
103
+ if (node.type === "subshell") {
104
+ out.push(makeUnit(node.text, context)); // never-weaker whole emit
105
+ descendCommandChildren(node, "subshell", out);
106
+ return;
107
+ }
108
+
109
+ if (COMMAND_ENUM_DESCEND.has(node.type)) {
110
+ descendCommandChildren(node, context, out);
111
+ return;
112
+ }
113
+
114
+ // Any other named statement (compound_statement `{ … }`, if/while/for/case,
115
+ // function_definition): emit whole, do not descend — deferred (#306).
116
+ out.push(makeUnit(node.text, context));
117
+ }
118
+
119
+ function makeUnit(
120
+ text: string,
121
+ context: BashCommandContext | undefined,
122
+ ): BashCommand {
123
+ return context ? { text, context } : { text };
124
+ }
125
+
126
+ function descendCommandChildren(
127
+ node: TSNode,
128
+ context: BashCommandContext | undefined,
129
+ out: BashCommand[],
130
+ ): void {
131
+ for (let i = 0; i < node.childCount; i++) {
132
+ const child = node.child(i);
133
+ if (child) collectCommandsInto(child, context, out);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Search a command's subtree for command/process substitutions and enumerate
139
+ * the commands inside them, tagged with the substitution's execution context.
140
+ * A substitution can nest under `command_name` (when the whole command is
141
+ * `$(…)`) or under an argument, so the entire subtree is searched.
142
+ */
143
+ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
144
+ for (let i = 0; i < node.childCount; i++) {
145
+ const child = node.child(i);
146
+ if (!child) continue;
147
+ const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
148
+ if (nestedContext) {
149
+ descendCommandChildren(child, nestedContext, out);
150
+ } else {
151
+ collectSubstitutionCommands(child, out);
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,493 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import {
3
+ ARG_NODE_TYPES,
4
+ SKIP_SUBTREE_TYPES,
5
+ } from "#src/access-intent/bash/node-text";
6
+ import type { TSNode } from "#src/access-intent/bash/parser";
7
+ import {
8
+ classifyTokenAsPathCandidate,
9
+ classifyTokenAsRuleCandidate,
10
+ } from "#src/access-intent/bash/token-classification";
11
+ import {
12
+ collectCommandTokens,
13
+ collectPathCandidateTokens,
14
+ collectRedirectTokens,
15
+ extractCommandName,
16
+ } from "#src/access-intent/bash/token-collection";
17
+ import { canonicalizePath } from "#src/canonicalize-path";
18
+ import {
19
+ getPathPolicyValues,
20
+ isPathWithinDirectory,
21
+ isSafeSystemPath,
22
+ normalizePathForComparison,
23
+ normalizePathPolicyLiteral,
24
+ } from "#src/path-utils";
25
+
26
+ // ── Internal types ───────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * The working directory in force where a path candidate appears.
30
+ *
31
+ * A `known` base carries an `offset` to be joined with `cwd` at resolution
32
+ * time: a relative-or-absolute path string built by folding the literal targets
33
+ * of current-shell `cd` commands (`""` = `cwd`); an absolute offset (from
34
+ * `cd /abs`) ignores `cwd` at resolution time.
35
+ * An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
36
+ * `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
37
+ */
38
+ type EffectiveBase =
39
+ | { readonly kind: "known"; readonly offset: string }
40
+ | { readonly kind: "unknown" };
41
+
42
+ /**
43
+ * A path-candidate token paired with the effective working directory projected
44
+ * onto the point in the command stream where it appears.
45
+ */
46
+ interface PathCandidate {
47
+ readonly token: string;
48
+ readonly base: EffectiveBase;
49
+ }
50
+
51
+ // ── Public output type ───────────────────────────────────────────────────────
52
+
53
+ export interface BashPathRuleCandidate {
54
+ /** Raw path-like token shown in prompts, logs, and session approvals. */
55
+ readonly token: string;
56
+ /** Equivalent values used for permission policy matching. */
57
+ readonly policyValues: readonly string[];
58
+ }
59
+
60
+ // ── Walk-time constants ──────────────────────────────────────────────────────
61
+
62
+ /** The working directory in force at the start of a program (`cwd`). */
63
+ const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
64
+
65
+ /** The effective directory after a non-literal or unresolvable `cd`. */
66
+ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
67
+
68
+ // ── AST walk — collect PathCandidates ───────────────────────────────────────
69
+
70
+ /**
71
+ * Walk the AST once, collecting every path-candidate token tagged with the
72
+ * effective working directory projected onto its position.
73
+ *
74
+ * The effective directory is stateful: it starts at `cwd` and each
75
+ * current-shell `cd <literal>` (joined by `&&`, `||`, `;`, or a newline)
76
+ * folds into it for subsequent commands.
77
+ * A `cd` inside a pipeline or a backgrounded command runs in a subshell and
78
+ * does not update the running directory; subshell and brace-group interiors
79
+ * inherit the enclosing base without folding their own `cd`s (a conservative
80
+ * first tier).
81
+ */
82
+ export function collectPathCandidates(rootNode: TSNode): PathCandidate[] {
83
+ const out: PathCandidate[] = [];
84
+ walkForCandidates(rootNode, CWD_BASE, out);
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Collect a single node's candidates tagged with `base`, returning the
90
+ * effective base in force *after* the node (the input base unless the node is
91
+ * a current-shell `cd <literal>` that folds the running directory).
92
+ */
93
+ function walkForCandidates(
94
+ node: TSNode,
95
+ base: EffectiveBase,
96
+ out: PathCandidate[],
97
+ ): EffectiveBase {
98
+ switch (node.type) {
99
+ case "program":
100
+ case "list":
101
+ case "redirected_statement":
102
+ return walkCurrentShellSequence(node, base, out);
103
+ case "command":
104
+ tagTokens(collectCommandTokens(node), base, out);
105
+ return foldCd(node, base);
106
+ case "pipeline":
107
+ // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
108
+ // first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
109
+ // `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside
110
+ // a node the `default` case treats as non-folding. Recover bash operator
111
+ // precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first
112
+ // stage's leading current-shell commands while keeping its terminal
113
+ // command and every downstream stage as non-folding subshells (#454).
114
+ return walkPipeline(node, base, out);
115
+ case "subshell":
116
+ // A subshell runs in a child shell: its interior `cd`s fold within the
117
+ // subshell but reset on exit, so the folded base is discarded.
118
+ walkCurrentShellSequence(node, base, out);
119
+ return base;
120
+ case "compound_statement":
121
+ // A `{ … }` brace group runs in the current shell, so its `cd`s persist
122
+ // to following commands — thread and return the folded base.
123
+ return walkCurrentShellSequence(node, base, out);
124
+ default:
125
+ // Pipelines, control-flow bodies, redirect targets, and command/process
126
+ // substitution interiors: collect every candidate in the subtree tagged
127
+ // with the enclosing base and do not fold their internal `cd`s. (Folding
128
+ // inside substitutions is deferred — conservative, never under-flags.)
129
+ tagTokens(collectPathCandidateTokens(node), base, out);
130
+ return base;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
136
+ * thread the effective base left-to-right through the children so a `cd`
137
+ * updates the base for following siblings.
138
+ * A statement immediately followed by the background operator (`&`) runs in a
139
+ * subshell, so its folded base is discarded.
140
+ */
141
+ function walkCurrentShellSequence(
142
+ seqNode: TSNode,
143
+ base: EffectiveBase,
144
+ out: PathCandidate[],
145
+ ): EffectiveBase {
146
+ let current = base;
147
+ for (let i = 0; i < seqNode.childCount; i++) {
148
+ const child = seqNode.child(i);
149
+ if (!child?.isNamed) continue;
150
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
151
+ const after = walkForCandidates(child, current, out);
152
+ current = isBackgrounded(seqNode, i) ? current : after;
153
+ }
154
+ return current;
155
+ }
156
+
157
+ /**
158
+ * Walk a `pipeline` node, returning the effective base in force after it.
159
+ *
160
+ * Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd`
161
+ * inside any stage must not leak — the base normally passes through unchanged.
162
+ * The exception is the first stage: tree-sitter-bash wraps a redirect-bearing
163
+ * current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage,
164
+ * and bash precedence makes the list's leading commands current-shell, so they
165
+ * fold and the folded base persists past the pipeline to following siblings.
166
+ *
167
+ * The terminal command of the first stage is the real pipe stage (a subshell)
168
+ * and must not fold; every stage after a `|` is a downstream subshell stage
169
+ * and collects tokens against the folded base without folding (#454).
170
+ */
171
+ function walkPipeline(
172
+ node: TSNode,
173
+ base: EffectiveBase,
174
+ out: PathCandidate[],
175
+ ): EffectiveBase {
176
+ let current = base;
177
+ let first = true;
178
+ for (let i = 0; i < node.childCount; i++) {
179
+ const child = node.child(i);
180
+ if (!child?.isNamed) continue;
181
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
182
+ if (first) {
183
+ current = foldPipelineFirstStage(child, current, out);
184
+ first = false;
185
+ continue;
186
+ }
187
+ // Downstream stage (after a `|`): subshell — collect against the folded
188
+ // base, do not fold.
189
+ tagTokens(collectPathCandidateTokens(child), current, out);
190
+ }
191
+ return current;
192
+ }
193
+
194
+ /**
195
+ * Collect the first pipe stage's candidates, folding its leading current-shell
196
+ * `cd` commands when tree-sitter wrapped a `list` or `redirected_statement`
197
+ * around them.
198
+ * The terminal command of that container is the real pipe stage (a subshell)
199
+ * and is collected without folding.
200
+ * A bare `command` first stage (a true pipeline first stage such as
201
+ * `cd nested | cat ../b`) is a subshell: it collects against the input base
202
+ * and does not fold.
203
+ */
204
+ function foldPipelineFirstStage(
205
+ node: TSNode,
206
+ base: EffectiveBase,
207
+ out: PathCandidate[],
208
+ ): EffectiveBase {
209
+ if (node.type === "list") return foldListExceptTerminal(node, base, out);
210
+ if (node.type === "redirected_statement") {
211
+ let current = base;
212
+ for (let i = 0; i < node.childCount; i++) {
213
+ const child = node.child(i);
214
+ if (!child?.isNamed) continue;
215
+ if (child.type === "file_redirect") {
216
+ // Redirect destinations are part of the piped stage; collect them
217
+ // against the folded base without folding.
218
+ tagTokens(collectRedirectTokens(child), current, out);
219
+ continue;
220
+ }
221
+ // The inner statement is the `list`/`command` being redirected; fold its
222
+ // leading current-shell commands via the terminal-excluding walk.
223
+ current = foldPipelineFirstStage(child, current, out);
224
+ }
225
+ return current;
226
+ }
227
+ // Bare `command` or any other shape: a true subshell first stage.
228
+ tagTokens(collectPathCandidateTokens(node), base, out);
229
+ return base;
230
+ }
231
+
232
+ /**
233
+ * Fold every named, non-skip child of a `list` except the last, threading the
234
+ * effective base left-to-right through the leading current-shell commands; the
235
+ * terminal child is the real pipe stage and is collected without folding.
236
+ */
237
+ function foldListExceptTerminal(
238
+ node: TSNode,
239
+ base: EffectiveBase,
240
+ out: PathCandidate[],
241
+ ): EffectiveBase {
242
+ const namedChildren: TSNode[] = [];
243
+ for (let i = 0; i < node.childCount; i++) {
244
+ const child = node.child(i);
245
+ if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) {
246
+ namedChildren.push(child);
247
+ }
248
+ }
249
+ let current = base;
250
+ for (let i = 0; i < namedChildren.length; i++) {
251
+ const child = namedChildren[i];
252
+ if (i < namedChildren.length - 1) {
253
+ current = walkForCandidates(child, current, out);
254
+ } else {
255
+ // Terminal child = the real pipe stage; collect without folding.
256
+ tagTokens(collectPathCandidateTokens(child), current, out);
257
+ }
258
+ }
259
+ return current;
260
+ }
261
+
262
+ /**
263
+ * True when the statement at `index` is immediately followed by the background
264
+ * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell
265
+ * separators.
266
+ */
267
+ function isBackgrounded(seqNode: TSNode, index: number): boolean {
268
+ const next = seqNode.child(index + 1);
269
+ if (!next || next.isNamed) return false;
270
+ return next.type === "&";
271
+ }
272
+
273
+ function tagTokens(
274
+ tokens: readonly string[],
275
+ base: EffectiveBase,
276
+ out: PathCandidate[],
277
+ ): void {
278
+ for (const token of tokens) out.push({ token, base });
279
+ }
280
+
281
+ // ── cd-fold helpers ──────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Compute the effective base after a command runs.
285
+ * Returns `base` unchanged unless the command is `cd`:
286
+ *
287
+ * - `cd /abs` (absolute literal) → a fresh known base, recovering from an
288
+ * earlier unknown base.
289
+ * - `cd rel` (relative literal) → fold into a known base, or stay unknown if
290
+ * the base was already unknown.
291
+ * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
292
+ * unknown.
293
+ */
294
+ function foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
295
+ if (extractCommandName(commandNode) !== "cd") return base;
296
+ const target = cdLiteralTarget(commandNode);
297
+ if (target === null) return UNKNOWN_BASE;
298
+ if (isAbsolute(target)) return { kind: "known", offset: target };
299
+ if (base.kind === "unknown") return UNKNOWN_BASE;
300
+ return { kind: "known", offset: join(base.offset, target) };
301
+ }
302
+
303
+ /**
304
+ * Resolve the literal target of a `cd` command, or `null` when the first
305
+ * argument is not a static literal (contains an expansion or command
306
+ * substitution) or cannot be resolved against the working directory (`cd -`,
307
+ * `cd ~…`, bare `cd`).
308
+ */
309
+ function cdLiteralTarget(commandNode: TSNode): string | null {
310
+ for (let i = 0; i < commandNode.childCount; i++) {
311
+ const child = commandNode.child(i);
312
+ if (!child) continue;
313
+ if (child.type === "command_name" || child.type === "variable_assignment")
314
+ continue;
315
+ if (!child.isNamed) continue;
316
+ // Skip the `--` end-of-flags marker; the next argument is the target.
317
+ if (child.type === "word" && child.text === "--") continue;
318
+ if (!ARG_NODE_TYPES.has(child.type)) return null;
319
+ return literalTextOf(child);
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * The literal string value of an argument node, or `null` when it contains a
326
+ * variable expansion / command substitution or is a non-resolvable `cd`
327
+ * destination (`-`, `~…`).
328
+ */
329
+ function literalTextOf(node: TSNode): string | null {
330
+ switch (node.type) {
331
+ case "word": {
332
+ const text = node.text;
333
+ if (text === "-" || text.startsWith("~")) return null;
334
+ return text;
335
+ }
336
+ case "raw_string": {
337
+ const text = node.text;
338
+ return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
339
+ ? text.slice(1, -1)
340
+ : text;
341
+ }
342
+ case "concatenation": {
343
+ let result = "";
344
+ for (let i = 0; i < node.childCount; i++) {
345
+ const child = node.child(i);
346
+ if (!child) continue;
347
+ const part = literalTextOf(child);
348
+ if (part === null) return null;
349
+ result += part;
350
+ }
351
+ return result;
352
+ }
353
+ case "string": {
354
+ let result = "";
355
+ for (let i = 0; i < node.childCount; i++) {
356
+ const child = node.child(i);
357
+ if (!child) continue;
358
+ if (child.type === '"') continue;
359
+ if (child.type !== "string_content") return null;
360
+ result += child.text;
361
+ }
362
+ return result;
363
+ }
364
+ default:
365
+ return null;
366
+ }
367
+ }
368
+
369
+ // ── Per-candidate helpers ────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * True when a path candidate is relative (resolved against the effective
373
+ * directory) rather than absolute (`/…`) or home-relative (`~…`), which are
374
+ * base-independent.
375
+ * Used to decide which candidates an unknown base affects.
376
+ */
377
+ function isRelativeCandidate(candidate: string): boolean {
378
+ return !candidate.startsWith("/") && !candidate.startsWith("~");
379
+ }
380
+
381
+ function getPolicyValuesForRuleCandidate(
382
+ candidate: string,
383
+ base: EffectiveBase,
384
+ cwd: string,
385
+ ): string[] {
386
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
387
+ const literal = normalizePathPolicyLiteral(candidate);
388
+ return literal ? [literal] : [];
389
+ }
390
+
391
+ const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
392
+ return getPathPolicyValues(candidate, { cwd, resolveBase });
393
+ }
394
+
395
+ // ── Projection functions ─────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Project a collection of path candidates into deduplicated external paths.
399
+ *
400
+ * Filters candidates through the strict path classifier
401
+ * (`classifyTokenAsPathCandidate`), resolves each against its effective working
402
+ * directory base, and returns only paths that resolve outside `cwd` in their
403
+ * lexical (as-typed, normalized but not symlink-resolved) form.
404
+ *
405
+ * The outside-`cwd` decision and the dedup identity use the canonical
406
+ * (symlink-resolved) form so `external_directory` config patterns match the
407
+ * path as the user typed it (#418).
408
+ */
409
+ export function projectExternalPaths(
410
+ candidates: readonly PathCandidate[],
411
+ cwd: string,
412
+ ): string[] {
413
+ const normalizedCwd = canonicalizePath(normalizePathForComparison(cwd, cwd));
414
+
415
+ const seen = new Set<string>();
416
+ const externalPaths: string[] = [];
417
+
418
+ for (const { token, base } of candidates) {
419
+ const candidate = classifyTokenAsPathCandidate(token);
420
+ if (!candidate) continue;
421
+
422
+ // Unknown effective directory: a relative candidate could resolve anywhere,
423
+ // so flag it conservatively (resolving against `cwd` only for a display
424
+ // path). Absolute / `~` candidates are base-independent and resolve below.
425
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
426
+ const lexical = normalizePathForComparison(candidate, cwd);
427
+ const canonical = canonicalizePath(lexical);
428
+ if (
429
+ canonical &&
430
+ normalizedCwd !== "" &&
431
+ !isSafeSystemPath(canonical) &&
432
+ !seen.has(canonical)
433
+ ) {
434
+ seen.add(canonical);
435
+ externalPaths.push(lexical);
436
+ }
437
+ continue;
438
+ }
439
+
440
+ const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
441
+ const lexical = normalizePathForComparison(candidate, resolveBase);
442
+ if (!lexical) continue;
443
+ // The boundary decision and dedup identity use the canonical
444
+ // (symlink-resolved) form, but the returned value is the lexical form so
445
+ // config patterns match the path as the user typed it (#418).
446
+ const canonical = canonicalizePath(lexical);
447
+
448
+ if (
449
+ normalizedCwd !== "" &&
450
+ !isSafeSystemPath(canonical) &&
451
+ !isPathWithinDirectory(canonical, normalizedCwd) &&
452
+ !seen.has(canonical)
453
+ ) {
454
+ seen.add(canonical);
455
+ externalPaths.push(lexical);
456
+ }
457
+ }
458
+
459
+ return externalPaths;
460
+ }
461
+
462
+ /**
463
+ * Project a collection of path candidates into rule candidates with their
464
+ * cd-aware policy lookup values.
465
+ *
466
+ * Filters candidates through the broad path classifier
467
+ * (`classifyTokenAsRuleCandidate`) and pairs each qualifying token with its
468
+ * set of policy values (absolute + project-relative + raw).
469
+ * A token after a non-literal `cd` keeps only its literal value so no
470
+ * spurious absolute rule can match (#393).
471
+ */
472
+ export function projectRuleCandidates(
473
+ candidates: readonly PathCandidate[],
474
+ cwd: string,
475
+ ): BashPathRuleCandidate[] {
476
+ const seen = new Set<string>();
477
+ const result: BashPathRuleCandidate[] = [];
478
+
479
+ for (const { token, base } of candidates) {
480
+ const candidate = classifyTokenAsRuleCandidate(token);
481
+ if (!candidate) continue;
482
+
483
+ const policyValues = getPolicyValuesForRuleCandidate(candidate, base, cwd);
484
+ if (policyValues.length === 0) continue;
485
+
486
+ const key = policyValues.join("\0");
487
+ if (seen.has(key)) continue;
488
+ seen.add(key);
489
+ result.push({ token: candidate, policyValues });
490
+ }
491
+
492
+ return result;
493
+ }