@gotgenes/pi-permission-system 16.0.0 → 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 (32) hide show
  1. package/CHANGELOG.md +14 -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} +136 -69
  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/composition-root.test.ts +80 -0
  26. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  27. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  28. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  29. package/test/handlers/gates/bash-path.test.ts +2 -2
  30. package/test/handlers/gates/external-directory.test.ts +0 -5
  31. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
  32. package/src/handlers/gates/bash-program.ts +0 -1028
@@ -0,0 +1,75 @@
1
+ import type { TSNode } from "#src/access-intent/bash/parser";
2
+
3
+ /**
4
+ * Node types whose subtrees must never be descended into for
5
+ * path extraction — their text content is not a command argument.
6
+ */
7
+ export const SKIP_SUBTREE_TYPES = new Set([
8
+ "heredoc_body",
9
+ "heredoc_end",
10
+ "comment",
11
+ ]);
12
+
13
+ /**
14
+ * Node types that represent argument values in the AST
15
+ * (word, concatenation, single-quoted string, double-quoted string).
16
+ */
17
+ export const ARG_NODE_TYPES = new Set([
18
+ "word",
19
+ "concatenation",
20
+ "string",
21
+ "raw_string",
22
+ ]);
23
+
24
+ /**
25
+ * Resolve the "shell value" of an argument node — the string the shell
26
+ * would pass to the command after quote removal.
27
+ *
28
+ * - `word` → `.text` (already unquoted)
29
+ * - `raw_string` → strip surrounding single quotes
30
+ * - `string` → strip surrounding double quotes, concatenate children text
31
+ * - `concatenation` → concatenate resolved children
32
+ * - other → `.text` as fallback
33
+ */
34
+ export function resolveNodeText(node: TSNode): string {
35
+ switch (node.type) {
36
+ case "word":
37
+ return node.text;
38
+ case "raw_string": {
39
+ // Strip surrounding single quotes: 'content' → content
40
+ const t = node.text;
41
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
42
+ return t.slice(1, -1);
43
+ }
44
+ return t;
45
+ }
46
+ case "string": {
47
+ // Double-quoted string: concatenate the resolved text of inner children,
48
+ // skipping the quote-delimiter nodes (literal `"`).
49
+ let result = "";
50
+ for (let i = 0; i < node.childCount; i++) {
51
+ const child = node.child(i);
52
+ if (!child) continue;
53
+ // Skip the literal `"` delimiters
54
+ if (child.type === '"') continue;
55
+ result += resolveNodeText(child);
56
+ }
57
+ return result;
58
+ }
59
+ case "string_content":
60
+ case "simple_expansion":
61
+ case "expansion":
62
+ return node.text;
63
+ case "concatenation": {
64
+ let result = "";
65
+ for (let i = 0; i < node.childCount; i++) {
66
+ const child = node.child(i);
67
+ if (!child) continue;
68
+ result += resolveNodeText(child);
69
+ }
70
+ return result;
71
+ }
72
+ default:
73
+ return node.text;
74
+ }
75
+ }
@@ -0,0 +1,42 @@
1
+ import { createRequire } from "node:module";
2
+ import { memoizeAsyncWithRetry } from "#src/async-cache";
3
+
4
+ /**
5
+ * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
6
+ * Defined locally so callers do not need to import web-tree-sitter types.
7
+ */
8
+ export interface TSNode {
9
+ readonly type: string;
10
+ readonly text: string;
11
+ readonly childCount: number;
12
+ /** False for anonymous tokens (operators, delimiters); true for named nodes. */
13
+ readonly isNamed: boolean;
14
+ child(index: number): TSNode | null;
15
+ }
16
+
17
+ /**
18
+ * Minimal subset of web-tree-sitter's Parser used by this module.
19
+ */
20
+ interface TSParser {
21
+ parse(input: string): { rootNode: TSNode; delete(): void } | null;
22
+ delete(): void;
23
+ }
24
+
25
+ async function initParser(): Promise<TSParser> {
26
+ // Use named imports — web-tree-sitter exports Parser as a named class.
27
+ const { Parser, Language } = await import("web-tree-sitter");
28
+ const req = createRequire(import.meta.url);
29
+ const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
30
+ await Parser.init({ locateFile: () => treeSitterWasm });
31
+
32
+ const parser = new Parser();
33
+ const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
34
+ const bash = await Language.load(bashWasm);
35
+ parser.setLanguage(bash);
36
+ return parser;
37
+ }
38
+
39
+ // Memoize on success but drop a rejected result so a transient init failure
40
+ // (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
41
+ // the parser for the process lifetime.
42
+ export const getParser = memoizeAsyncWithRetry(initParser);
@@ -0,0 +1,102 @@
1
+ import {
2
+ type BashCommand,
3
+ collectCommands,
4
+ } from "#src/access-intent/bash/command-enumeration";
5
+ import {
6
+ type BashPathRuleCandidate,
7
+ collectPathCandidates,
8
+ projectExternalPaths,
9
+ projectRuleCandidates,
10
+ } from "#src/access-intent/bash/cwd-projection";
11
+ import { getParser } from "#src/access-intent/bash/parser";
12
+
13
+ export type { BashCommand, BashPathRuleCandidate };
14
+
15
+ /**
16
+ * A bash command parsed once into a born-ready representation.
17
+ *
18
+ * Parsing is the expensive step (tree-sitter WASM); `BashProgram` performs it
19
+ * a single time and eagerly resolves all three typed slices so the bash
20
+ * permission gates do not each re-parse or re-walk the command, and so the
21
+ * slices are guaranteed to agree.
22
+ *
23
+ * Construct via the async `parse()` factory; the constructor is private.
24
+ */
25
+ export class BashProgram {
26
+ private constructor(
27
+ private readonly commandUnits: readonly BashCommand[],
28
+ private readonly resolvedExternalPaths: readonly string[],
29
+ private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
30
+ ) {}
31
+
32
+ /**
33
+ * Parse a bash command into a born-ready `BashProgram`.
34
+ *
35
+ * Uses tree-sitter-bash to build the full AST, enumerates command units and
36
+ * walks path-candidate tokens once, then eagerly resolves all three slices
37
+ * against `cwd`. Heredoc bodies, comments, and other non-argument content are
38
+ * skipped. An unparseable command yields an empty program.
39
+ */
40
+ static async parse(command: string, cwd: string): Promise<BashProgram> {
41
+ const parser = await getParser();
42
+ const tree = parser.parse(command);
43
+ if (!tree) return new BashProgram([], [], []);
44
+
45
+ try {
46
+ const candidates = collectPathCandidates(tree.rootNode);
47
+ return new BashProgram(
48
+ collectCommands(tree.rootNode),
49
+ projectExternalPaths(candidates, cwd),
50
+ projectRuleCandidates(candidates, cwd),
51
+ );
52
+ } finally {
53
+ tree.delete();
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The top-level command-pattern units of the chain, in source order.
59
+ *
60
+ * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
61
+ * quotes, command substitution, and subshells are respected by the parser and
62
+ * are NOT split — a subshell or other compound statement is emitted whole.
63
+ * May be empty (e.g. an empty command or a comment-only line); callers fall
64
+ * back to the whole command so the surface is never evaluated weaker than
65
+ * before.
66
+ */
67
+ // Used by resolveBashCommandCheck (bash-command.ts) and tests. Fallow's
68
+ // syntactic analysis cannot resolve the static-factory return type (private
69
+ // ctor), so it reports a false positive here.
70
+ // fallow-ignore-next-line unused-class-member
71
+ commands(): BashCommand[] {
72
+ return [...this.commandUnits];
73
+ }
74
+
75
+ /**
76
+ * Deduplicated paths that resolve outside `cwd`, in their lexical (as-typed,
77
+ * normalized but not symlink-resolved) form.
78
+ *
79
+ * Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
80
+ * The outside-`cwd` decision and the dedup identity use the canonical
81
+ * (symlink-resolved) form, but the returned value is the lexical form so
82
+ * `external_directory` config patterns match the path as the user typed it
83
+ * (#418); the gate re-derives the canonical alias for matching.
84
+ */
85
+ externalPaths(): string[] {
86
+ return [...this.resolvedExternalPaths];
87
+ }
88
+
89
+ /**
90
+ * Path-rule candidates paired with their policy lookup values.
91
+ *
92
+ * Resolved eagerly at parse time against the `cwd` supplied to `parse()`.
93
+ * Each token is resolved against the effective working directory in force at
94
+ * the token's position (folding literal current-shell `cd` commands), while
95
+ * raw and project-relative aliases are retained for backward-compatible
96
+ * relative rules. A token after a non-literal `cd` keeps only its literal
97
+ * value so no spurious absolute rule can match (#393).
98
+ */
99
+ pathRuleCandidates(): BashPathRuleCandidate[] {
100
+ return [...this.resolvedRuleCandidates];
101
+ }
102
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure, synchronous token-classification helpers for bash path extraction.
3
3
  *
4
- * Exports two classifiers consumed by `bash-program.ts`:
4
+ * Exports two classifiers consumed by `cwd-projection.ts`:
5
5
  * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
6
6
  * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
7
7
  *
@@ -0,0 +1,351 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ ARG_NODE_TYPES,
4
+ resolveNodeText,
5
+ SKIP_SUBTREE_TYPES,
6
+ } from "#src/access-intent/bash/node-text";
7
+ import type { TSNode } from "#src/access-intent/bash/parser";
8
+
9
+ // ── Public surface ─────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Recursively visit the AST and collect resolved text of nodes that
13
+ * represent command arguments or redirect destinations.
14
+ *
15
+ * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
16
+ *
17
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
18
+ * argument skipping to avoid collecting inline patterns/scripts
19
+ * as path candidates. For all other commands, collects all
20
+ * arguments generically.
21
+ */
22
+ export function collectPathCandidateTokens(node: TSNode): string[] {
23
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
24
+ if (node.type === "command") return collectCommandTokens(node);
25
+ if (node.type === "file_redirect") return collectRedirectTokens(node);
26
+
27
+ const tokens: string[] = [];
28
+ for (let i = 0; i < node.childCount; i++) {
29
+ const child = node.child(i);
30
+ if (child) tokens.push(...collectPathCandidateTokens(child));
31
+ }
32
+ return tokens;
33
+ }
34
+
35
+ /**
36
+ * Select the collection strategy for a `command` node: pattern-first
37
+ * commands use `collectPatternCommandTokens`; all others use
38
+ * `collectGenericCommandTokens`.
39
+ */
40
+ export function collectCommandTokens(node: TSNode): string[] {
41
+ const commandName = extractCommandName(node);
42
+ const config = commandName
43
+ ? PATTERN_FIRST_COMMANDS.get(commandName)
44
+ : undefined;
45
+ return config
46
+ ? collectPatternCommandTokens(node, config)
47
+ : collectGenericCommandTokens(node);
48
+ }
49
+
50
+ /**
51
+ * Collect redirect-destination tokens from a `file_redirect` node.
52
+ */
53
+ export function collectRedirectTokens(node: TSNode): string[] {
54
+ const tokens: string[] = [];
55
+ for (let i = 0; i < node.childCount; i++) {
56
+ const child = node.child(i);
57
+ if (!child) continue;
58
+ if (ARG_NODE_TYPES.has(child.type)) {
59
+ tokens.push(resolveNodeText(child));
60
+ }
61
+ }
62
+ return tokens;
63
+ }
64
+
65
+ /**
66
+ * Extract the command name from a `command` node.
67
+ * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
68
+ * if the command name cannot be determined (e.g. variable expansion).
69
+ */
70
+ export function extractCommandName(node: TSNode): string | undefined {
71
+ for (let i = 0; i < node.childCount; i++) {
72
+ const child = node.child(i);
73
+ if (!child) continue;
74
+ if (child.type === "command_name") {
75
+ const text = resolveNodeText(child);
76
+ return text ? basename(text) : undefined;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ // ── Private helpers and config ─────────────────────────────────────────────
83
+
84
+ interface PatternCommandConfig {
85
+ /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
86
+ readonly argConsumingFlags: ReadonlySet<string>;
87
+ /** Flags that consume the next argument as a file path */
88
+ readonly fileConsumingFlags: ReadonlySet<string>;
89
+ /**
90
+ * Number of leading positional arguments that are patterns/scripts, not paths.
91
+ * Default: 1 (covers sed, awk, grep, rg).
92
+ * sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
93
+ */
94
+ readonly patternPositionals?: number;
95
+ }
96
+
97
+ /**
98
+ * Commands whose first N positional arguments are inline patterns/scripts,
99
+ * not filesystem paths. The map stores per-command flag configuration so
100
+ * the walker can correctly identify which arguments are consumed by flags
101
+ * vs. which are positional.
102
+ */
103
+ const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
104
+ new Map([
105
+ [
106
+ "sed",
107
+ {
108
+ argConsumingFlags: new Set(["-e", "-i"]),
109
+ fileConsumingFlags: new Set(["-f"]),
110
+ },
111
+ ],
112
+ [
113
+ "awk",
114
+ {
115
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
116
+ fileConsumingFlags: new Set(["-f"]),
117
+ },
118
+ ],
119
+ [
120
+ "gawk",
121
+ {
122
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
123
+ fileConsumingFlags: new Set(["-f"]),
124
+ },
125
+ ],
126
+ [
127
+ "nawk",
128
+ {
129
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
130
+ fileConsumingFlags: new Set(["-f"]),
131
+ },
132
+ ],
133
+ [
134
+ "grep",
135
+ {
136
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
137
+ fileConsumingFlags: new Set(["-f"]),
138
+ },
139
+ ],
140
+ [
141
+ "egrep",
142
+ {
143
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
144
+ fileConsumingFlags: new Set(["-f"]),
145
+ },
146
+ ],
147
+ [
148
+ "fgrep",
149
+ {
150
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
151
+ fileConsumingFlags: new Set(["-f"]),
152
+ },
153
+ ],
154
+ [
155
+ "rg",
156
+ {
157
+ argConsumingFlags: new Set([
158
+ "-e",
159
+ "-A",
160
+ "-B",
161
+ "-C",
162
+ "-m",
163
+ "-g",
164
+ "-t",
165
+ "-T",
166
+ "-j",
167
+ "-M",
168
+ "-r",
169
+ "-E",
170
+ ]),
171
+ fileConsumingFlags: new Set(["-f"]),
172
+ },
173
+ ],
174
+ [
175
+ "sd",
176
+ {
177
+ argConsumingFlags: new Set(["-n", "-f"]),
178
+ fileConsumingFlags: new Set([]),
179
+ patternPositionals: 2,
180
+ },
181
+ ],
182
+ ]);
183
+
184
+ /**
185
+ * Describes what the walker should do when it encounters a flag word inside
186
+ * a pattern-first command. Using a discriminated union lets the `switch` in
187
+ * `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
188
+ * assertion (which would trigger the Biome/ESLint assertion conflict).
189
+ */
190
+ type PatternCommandFlagDirective =
191
+ | { kind: "end-of-flags" }
192
+ | { kind: "regular-flag" }
193
+ | {
194
+ kind: "consume-arg";
195
+ nextArgAction: "skip" | "extract";
196
+ setsExplicitScript: boolean;
197
+ };
198
+
199
+ /**
200
+ * Classify a flag word from a pattern-first command into a directive that
201
+ * tells the walker how to handle the flag and its following argument.
202
+ */
203
+ function classifyPatternCommandFlag(
204
+ text: string,
205
+ config: PatternCommandConfig,
206
+ ): PatternCommandFlagDirective {
207
+ if (text === "--") return { kind: "end-of-flags" };
208
+ if (config.argConsumingFlags.has(text)) {
209
+ return {
210
+ kind: "consume-arg",
211
+ nextArgAction: "skip",
212
+ setsExplicitScript: text === "-e" || text === "-f",
213
+ };
214
+ }
215
+ if (config.fileConsumingFlags.has(text)) {
216
+ return {
217
+ kind: "consume-arg",
218
+ nextArgAction: "extract",
219
+ setsExplicitScript: true,
220
+ };
221
+ }
222
+ return { kind: "regular-flag" };
223
+ }
224
+
225
+ /**
226
+ * Collect path-candidate tokens from a command known to have
227
+ * pattern/script arguments in leading positional slots.
228
+ *
229
+ * Uses position-based skipping: the first N positional arguments
230
+ * (where N = patternPositionals, default 1) are assumed to be
231
+ * inline patterns/scripts and are skipped. Remaining positional
232
+ * arguments are collected as path candidates.
233
+ *
234
+ * Flags listed in `argConsumingFlags` consume the next argument
235
+ * (skipped). Flags in `fileConsumingFlags` consume the next
236
+ * argument as a file path (collected). The flags `-e` and `-f`
237
+ * additionally signal that an explicit script was provided via
238
+ * flag, so no inline positional script is expected.
239
+ */
240
+ function collectPatternCommandTokens(
241
+ node: TSNode,
242
+ config: PatternCommandConfig,
243
+ ): string[] {
244
+ const patternPositionals = config.patternPositionals ?? 1;
245
+ let hasExplicitScript = false;
246
+ let positionalsSeen = 0;
247
+ let nextArgAction: "skip" | "extract" | null = null;
248
+ let pastEndOfFlags = false;
249
+ const tokens: string[] = [];
250
+
251
+ for (let i = 0; i < node.childCount; i++) {
252
+ const child = node.child(i);
253
+ if (!child) continue;
254
+
255
+ // Skip command_name and variable_assignment nodes.
256
+ if (child.type === "command_name" || child.type === "variable_assignment")
257
+ continue;
258
+
259
+ // Only process argument-like nodes; recurse into others
260
+ // (e.g. command_substitution) for nested commands.
261
+ if (!ARG_NODE_TYPES.has(child.type)) {
262
+ tokens.push(...collectPathCandidateTokens(child));
263
+ continue;
264
+ }
265
+
266
+ const text = resolveNodeText(child);
267
+
268
+ // Handle consumed argument from previous flag.
269
+ if (nextArgAction === "skip") {
270
+ nextArgAction = null;
271
+ continue;
272
+ }
273
+ if (nextArgAction === "extract") {
274
+ tokens.push(text);
275
+ nextArgAction = null;
276
+ continue;
277
+ }
278
+
279
+ // Flag detection (only before "--" end-of-flags marker).
280
+ if (
281
+ !pastEndOfFlags &&
282
+ child.type === "word" &&
283
+ text.startsWith("-") &&
284
+ text.length > 1
285
+ ) {
286
+ const directive = classifyPatternCommandFlag(text, config);
287
+ switch (directive.kind) {
288
+ case "end-of-flags":
289
+ pastEndOfFlags = true;
290
+ break;
291
+ case "consume-arg":
292
+ nextArgAction = directive.nextArgAction;
293
+ if (directive.setsExplicitScript) hasExplicitScript = true;
294
+ break;
295
+ case "regular-flag":
296
+ break;
297
+ }
298
+ continue;
299
+ }
300
+
301
+ // Positional argument.
302
+ if (!hasExplicitScript && positionalsSeen < patternPositionals) {
303
+ positionalsSeen++;
304
+ continue; // Skip: this is an inline pattern/script.
305
+ }
306
+
307
+ // File argument — collect as path candidate.
308
+ tokens.push(text);
309
+ }
310
+
311
+ return tokens;
312
+ }
313
+
314
+ /**
315
+ * Collect all argument tokens from a generic (non-pattern-first) command node,
316
+ * skipping the command name and variable assignments.
317
+ */
318
+ function collectGenericCommandTokens(node: TSNode): string[] {
319
+ const tokens: string[] = [];
320
+ let seenCommandName = false;
321
+
322
+ for (let i = 0; i < node.childCount; i++) {
323
+ const child = node.child(i);
324
+ if (!child) continue;
325
+
326
+ if (child.type === "command_name") {
327
+ seenCommandName = true;
328
+ continue;
329
+ }
330
+ // Skip variable_assignment nodes (FOO=/bar)
331
+ if (child.type === "variable_assignment") continue;
332
+
333
+ // If there was no explicit command_name node, the first word-like
334
+ // child is the command name itself — skip it.
335
+ if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
336
+ seenCommandName = true;
337
+ continue;
338
+ }
339
+
340
+ // Argument nodes: resolve their text and collect.
341
+ if (ARG_NODE_TYPES.has(child.type)) {
342
+ tokens.push(resolveNodeText(child));
343
+ continue;
344
+ }
345
+
346
+ // Recurse into other children (e.g. command_substitution nested in args)
347
+ tokens.push(...collectPathCandidateTokens(child));
348
+ }
349
+
350
+ return tokens;
351
+ }
@@ -1,4 +1,4 @@
1
- import type { BashCommand } from "#src/handlers/gates/bash-program";
1
+ import type { BashCommand } from "#src/access-intent/bash/command-enumeration";
2
2
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import type { PermissionCheckResult } from "#src/types";
@@ -1,10 +1,10 @@
1
+ import type { BashProgram } from "#src/access-intent/bash/program";
1
2
  import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import { getExternalDirectoryPolicyValues } from "#src/path-utils";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
5
  import { SessionApproval } from "#src/session-approval";
5
6
  import { deriveApprovalPattern } from "#src/session-rules";
6
7
  import type { PermissionCheckResult } from "#src/types";
7
- import type { BashProgram } from "./bash-program";
8
8
  import { pickMostRestrictive } from "./candidate-check";
9
9
  import type { GateResult } from "./descriptor";
10
10
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
@@ -24,14 +24,14 @@ export function describeBashExternalDirectoryGate(
24
24
  bashProgram: BashProgram | null,
25
25
  resolver: ScopedPermissionResolver,
26
26
  ): GateResult {
27
- if (tcc.toolName !== "bash" || !tcc.cwd) return null;
27
+ if (tcc.toolName !== "bash") return null;
28
28
 
29
29
  const command = getNonEmptyString(toRecord(tcc.input).command);
30
30
  if (!command) return null;
31
31
 
32
32
  if (!bashProgram) return null;
33
33
 
34
- const externalPaths = bashProgram.externalPaths(tcc.cwd);
34
+ const externalPaths = bashProgram.externalPaths();
35
35
  if (externalPaths.length === 0) return null;
36
36
 
37
37
  // Collect paths whose resolved state is not already "allow".
@@ -1,4 +1,4 @@
1
- import { BashProgram } from "./bash-program";
1
+ import { BashProgram } from "#src/access-intent/bash/program";
2
2
 
3
3
  /**
4
4
  * Extract paths from a bash command string that resolve outside CWD.
@@ -11,5 +11,5 @@ export async function extractExternalPathsFromBashCommand(
11
11
  command: string,
12
12
  cwd: string,
13
13
  ): Promise<string[]> {
14
- return (await BashProgram.parse(command)).externalPaths(cwd);
14
+ return (await BashProgram.parse(command, cwd)).externalPaths();
15
15
  }
@@ -1,9 +1,9 @@
1
+ import type { BashProgram } from "#src/access-intent/bash/program";
1
2
  import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { SessionApproval } from "#src/session-approval";
4
5
  import { deriveApprovalPattern } from "#src/session-rules";
5
6
  import type { PermissionCheckResult } from "#src/types";
6
- import type { BashProgram } from "./bash-program";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatPathAskPrompt } from "./path";
@@ -36,7 +36,7 @@ export function describeBashPathGate(
36
36
 
37
37
  if (!bashProgram) return null;
38
38
 
39
- const candidates = bashProgram.pathRuleCandidates(tcc.cwd);
39
+ const candidates = bashProgram.pathRuleCandidates();
40
40
  if (candidates.length === 0) return null;
41
41
  const tokens = candidates.map(({ token }) => token);
42
42
 
@@ -28,8 +28,6 @@ export function describeExternalDirectoryGate(
28
28
  resolver: ScopedPermissionResolver,
29
29
  extractors?: ToolAccessExtractorLookup,
30
30
  ): GateResult {
31
- if (!tcc.cwd) return null;
32
-
33
31
  const externalDirectoryPath = getToolInputPath(
34
32
  tcc.toolName,
35
33
  tcc.input,
@@ -37,9 +37,7 @@ export function describePathGate(
37
37
 
38
38
  // Resolve to the canonical (cwd-anchored, absolute) path so the approval
39
39
  // pattern matches the policy values a later call produces.
40
- const approvalPath = tcc.cwd
41
- ? normalizePathForComparison(filePath, tcc.cwd)
42
- : filePath;
40
+ const approvalPath = normalizePathForComparison(filePath, tcc.cwd);
43
41
  const pattern = deriveApprovalPattern(approvalPath);
44
42
 
45
43
  const descriptor: GateDescriptor = {
@@ -29,10 +29,6 @@ export function describeSkillReadGate(
29
29
  return null;
30
30
  }
31
31
 
32
- if (tcc.cwd === undefined) {
33
- return null;
34
- }
35
-
36
32
  const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
37
33
  const matchedSkill = findSkillPathMatch(
38
34
  normalizedReadPath,