@gotgenes/pi-permission-system 27.0.1 → 27.1.1

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 (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +20 -2
  3. package/config/config.example.json +3 -1
  4. package/dist/public.d.ts +21 -1
  5. package/docs/configuration.md +184 -5
  6. package/package.json +3 -1
  7. package/schemas/permissions.schema.json +52 -4
  8. package/src/access-intent/bash/bash-path-resolver.ts +104 -34
  9. package/src/access-intent/bash/command-effects.ts +305 -0
  10. package/src/access-intent/bash/command-enumeration.ts +116 -40
  11. package/src/access-intent/bash/program.ts +15 -11
  12. package/src/access-intent/bash/redirect-analysis.ts +98 -0
  13. package/src/access-intent/bash/token-classification.ts +14 -13
  14. package/src/access-intent/bash/token-collection.ts +102 -22
  15. package/src/access-intent/bash/wrapper-analysis.ts +101 -11
  16. package/src/access-intent/effect.ts +56 -0
  17. package/src/access-intent/input-normalizer.ts +2 -2
  18. package/src/access-intent/path-surfaces.ts +110 -4
  19. package/src/authority/delegation-envelope.ts +18 -7
  20. package/src/config-schema.ts +95 -6
  21. package/src/handlers/gates/bash-command.ts +53 -17
  22. package/src/handlers/gates/bash-external-directory.ts +41 -8
  23. package/src/handlers/gates/bash-path-extractor.ts +3 -3
  24. package/src/handlers/gates/bash-path.ts +31 -13
  25. package/src/handlers/gates/external-directory-policy.ts +37 -13
  26. package/src/handlers/gates/external-directory.ts +11 -4
  27. package/src/handlers/gates/path.ts +12 -5
  28. package/src/handlers/gates/tool.ts +22 -0
  29. package/src/normalize.ts +70 -1
  30. package/src/permission-manager.ts +3 -2
  31. package/src/permission-resolver.ts +23 -4
  32. package/src/presentation/path-ask-payload.ts +17 -6
  33. package/src/restrictiveness.ts +48 -0
  34. package/src/rule.ts +13 -13
  35. package/src/scope-merge.ts +8 -2
  36. package/src/session-rules.ts +19 -8
  37. package/src/types.ts +22 -1
  38. package/src/handlers/gates/candidate-check.ts +0 -32
@@ -3,13 +3,15 @@ import {
3
3
  forEachNestedExecution,
4
4
  } from "#src/access-intent/bash/nested-execution";
5
5
  import type { TSNode } from "#src/access-intent/bash/parser";
6
+ import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
6
7
  import {
7
8
  type CommandWord,
8
9
  classifyWrapperWords,
9
10
  executedUnitOf,
11
+ isTransparentWrapper,
10
12
  type WrapperKind,
11
13
  } from "#src/access-intent/bash/wrapper-analysis";
12
- import type { BashCommandContext } from "#src/types";
14
+ import type { BashCommandContext, FloorExemption } from "#src/types";
13
15
 
14
16
  export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
15
17
 
@@ -37,25 +39,55 @@ export interface BashCommand {
37
39
  */
38
40
  readonly wrapperKind?: WrapperKind;
39
41
  /**
40
- * The command this wrapper unit actually runs (#713). Display-only — it is
41
- * never gated on its own, so the wrapper floor still applies. Absent for an
42
- * ordinary command, and for a wrapper whose inner command cannot be
43
- * established.
42
+ * The command this wrapper unit actually runs (#713). Absent for an ordinary
43
+ * command, and for a wrapper whose inner command cannot be established.
44
+ *
45
+ * Display-only, and deliberately looks past an `sh -c` layer the gate must
46
+ * not look past — {@link floorExemption} is the gateable answer, established
47
+ * by its own walk rather than read off this string (#803).
44
48
  */
45
49
  readonly executedUnit?: string;
50
+ /**
51
+ * Set when this wrapper unit's floor has no reason left to hold, naming the
52
+ * reason (#803). Only ever present alongside `wrapperKind: "indirection"`
53
+ * and an established {@link executedUnit}.
54
+ */
55
+ readonly floorExemption?: FloorExemption;
56
+ }
57
+
58
+ /**
59
+ * What the statement enclosing a command unit establishes about it.
60
+ *
61
+ * Both facts flow down the walk together because both are the *statement's*,
62
+ * not the command's: a subshell's commands run in a subshell however they are
63
+ * spelled, and a redirected statement writes a file however read-only the
64
+ * command in front of the operator is.
65
+ */
66
+ interface UnitScope {
67
+ /**
68
+ * Execution context for a nested command (substitution or subshell); absent
69
+ * for a current-shell (top-level) command.
70
+ */
71
+ readonly context?: BashCommandContext;
72
+ /**
73
+ * True when the enclosing statement redirects output into a real file, which
74
+ * withholds the floor exemption from any wrapper unit beneath it.
75
+ */
76
+ readonly writesViaRedirect: boolean;
46
77
  }
47
78
 
79
+ /** A top-level command in the current shell, writing no file. */
80
+ const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
81
+
48
82
  // ── Command enumeration ──────────────────────────────────────────────────────
49
83
 
50
84
  /**
51
- * Container node types descended into when enumerating command units.
85
+ * Container node types descended into with the enclosing scope unchanged.
86
+ *
87
+ * `redirected_statement` is descended too, but has its own branch: it is the
88
+ * node that can establish a write, so it descends with a scope of its own.
52
89
  */
53
- const COMMAND_ENUM_DESCEND = new Set([
54
- "program",
55
- "list",
56
- "pipeline",
57
- "redirected_statement",
58
- ]);
90
+ const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
59
91
 
60
92
  /**
61
93
  * Named node types abandoned during command enumeration: they are neither
@@ -95,13 +127,13 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
95
127
  */
96
128
  export function collectCommands(node: TSNode): BashCommand[] {
97
129
  const out: BashCommand[] = [];
98
- collectCommandsInto(node, undefined, out);
130
+ collectCommandsInto(node, TOP_LEVEL_SCOPE, out);
99
131
  return out;
100
132
  }
101
133
 
102
134
  function collectCommandsInto(
103
135
  node: TSNode,
104
- context: BashCommandContext | undefined,
136
+ scope: UnitScope,
105
137
  out: BashCommand[],
106
138
  ): void {
107
139
  // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
@@ -110,13 +142,18 @@ function collectCommandsInto(
110
142
  if (COMMAND_ENUM_SKIP.has(node.type)) return;
111
143
 
112
144
  if (node.type === "command") {
113
- out.push(makeCommandUnit(node, context));
145
+ out.push(makeCommandUnit(node, scope));
114
146
  // A command's text already contains any substitution; descend its subtree
115
147
  // to ALSO emit the inner commands of command/process substitutions.
116
148
  collectHostedCommands(node, out);
117
149
  return;
118
150
  }
119
151
 
152
+ if (node.type === "redirected_statement") {
153
+ descendCommandChildren(node, redirectedScope(node, scope), out);
154
+ return;
155
+ }
156
+
120
157
  if (EXECUTION_HOST_TYPES.has(node.type)) {
121
158
  // Not a command itself, but its subtree can host one that really runs
122
159
  // (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
@@ -125,48 +162,80 @@ function collectCommandsInto(
125
162
  }
126
163
 
127
164
  if (node.type === "subshell") {
128
- out.push(makeUnit(node.text, context)); // never-weaker whole emit
129
- descendCommandChildren(node, "subshell", out);
165
+ out.push(makeUnit(node.text, scope)); // never-weaker whole emit
166
+ descendCommandChildren(node, { ...scope, context: "subshell" }, out);
130
167
  return;
131
168
  }
132
169
 
133
170
  if (COMMAND_ENUM_DESCEND.has(node.type)) {
134
- descendCommandChildren(node, context, out);
171
+ descendCommandChildren(node, scope, out);
135
172
  return;
136
173
  }
137
174
 
138
175
  // Any other named statement (compound_statement `{ … }`, if/while/for/case,
139
176
  // function_definition): emit whole, do not descend — deferred (#306).
140
- out.push(makeUnit(node.text, context));
177
+ out.push(makeUnit(node.text, scope));
178
+ }
179
+
180
+ /** The wrapper facts a `command` node's words establish about its unit. */
181
+ interface WrapperFacts {
182
+ readonly wrapperKind?: WrapperKind;
183
+ readonly executedUnit?: string;
184
+ readonly floorExemption?: FloorExemption;
141
185
  }
142
186
 
143
187
  function makeUnit(
144
188
  text: string,
145
- context: BashCommandContext | undefined,
146
- wrapperKind?: WrapperKind,
147
- executedUnit?: string,
189
+ scope: UnitScope,
190
+ wrapper: WrapperFacts = {},
148
191
  ): BashCommand {
149
- const unit: BashCommand = context ? { text, context } : { text };
150
- const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
151
- return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
192
+ const { wrapperKind, executedUnit, floorExemption } = wrapper;
193
+ const scoped: BashCommand = scope.context
194
+ ? { text, context: scope.context }
195
+ : { text };
196
+ const flagged = wrapperKind ? { ...scoped, wrapperKind } : scoped;
197
+ const named =
198
+ executedUnit === undefined ? flagged : { ...flagged, executedUnit };
199
+ return floorExemption === undefined ? named : { ...named, floorExemption };
152
200
  }
153
201
 
154
202
  /**
155
- * Build the unit for a `command` node, reading its words once to answer both
156
- * wrapper questions: whether the unit is floored, and what it actually runs.
203
+ * Build the unit for a `command` node, reading its words once to answer all
204
+ * three wrapper questions: whether the unit is floored, what it actually runs,
205
+ * and whether the floor still has a reason to hold.
157
206
  */
158
- function makeCommandUnit(
159
- node: TSNode,
160
- context: BashCommandContext | undefined,
161
- ): BashCommand {
207
+ function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
162
208
  const text = commandUnitText(node);
163
209
  const words = readCommandWords(node);
164
- return makeUnit(
165
- text,
166
- context,
167
- classifyWrapperWords(words),
168
- executedUnitOf(text, words) ?? undefined,
169
- );
210
+ return makeUnit(text, scope, {
211
+ wrapperKind: classifyWrapperWords(words),
212
+ executedUnit: executedUnitOf(text, words) ?? undefined,
213
+ floorExemption: isTransparentWrapper(words, scope)
214
+ ? "core-reader"
215
+ : undefined,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * The scope a `redirected_statement`'s children run under: the enclosing one,
221
+ * plus a write unless every one of its redirects provably only reads.
222
+ *
223
+ * The redirect belongs to the last element of a pipeline, but it hangs off the
224
+ * whole statement in the parse tree, so every command beneath it is marked.
225
+ * Over-attributing is the fail-closed direction — the flag can only withhold an
226
+ * exemption, never grant one — which is also why the question asked of each
227
+ * redirect is a refusal rather than a proof.
228
+ */
229
+ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
230
+ if (scope.writesViaRedirect) return scope;
231
+ for (let i = 0; i < node.childCount; i++) {
232
+ const child = node.child(i);
233
+ if (child?.type !== "file_redirect") continue;
234
+ if (redirectMayWriteFile(child)) {
235
+ return { ...scope, writesViaRedirect: true };
236
+ }
237
+ }
238
+ return scope;
170
239
  }
171
240
 
172
241
  /**
@@ -213,12 +282,12 @@ function commandUnitText(node: TSNode): string {
213
282
 
214
283
  function descendCommandChildren(
215
284
  node: TSNode,
216
- context: BashCommandContext | undefined,
285
+ scope: UnitScope,
217
286
  out: BashCommand[],
218
287
  ): void {
219
288
  for (let i = 0; i < node.childCount; i++) {
220
289
  const child = node.child(i);
221
- if (child) collectCommandsInto(child, context, out);
290
+ if (child) collectCommandsInto(child, scope, out);
222
291
  }
223
292
  }
224
293
 
@@ -232,6 +301,13 @@ function descendCommandChildren(
232
301
  */
233
302
  function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
234
303
  forEachNestedExecution(node, (contextNode, context) => {
235
- descendCommandChildren(contextNode, context, out);
304
+ // A nested execution starts fresh: an enclosing statement's redirect is
305
+ // that statement's, not the substitution's, exactly as #807 attributes a
306
+ // nested command's path tokens to its own command.
307
+ descendCommandChildren(
308
+ contextNode,
309
+ { context, writesViaRedirect: false },
310
+ out,
311
+ );
236
312
  });
237
313
  }
@@ -1,5 +1,5 @@
1
- import type { AccessPath } from "#src/access-intent/access-path";
2
1
  import {
2
+ type BashExternalPath,
3
3
  BashPathResolver,
4
4
  type BashPathRuleCandidate,
5
5
  } from "#src/access-intent/bash/bash-path-resolver";
@@ -10,7 +10,7 @@ import {
10
10
  import { getParser } from "#src/access-intent/bash/parser";
11
11
  import type { PathNormalizer } from "#src/path-normalizer";
12
12
 
13
- export type { BashCommand, BashPathRuleCandidate };
13
+ export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
14
14
 
15
15
  /**
16
16
  * A bash command parsed once into a born-ready representation.
@@ -26,7 +26,7 @@ export class BashProgram {
26
26
  private constructor(
27
27
  private readonly sourceCommand: string,
28
28
  private readonly commandUnits: readonly BashCommand[],
29
- private readonly resolvedExternalPaths: readonly AccessPath[],
29
+ private readonly resolvedExternalAccesses: readonly BashExternalPath[],
30
30
  private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
31
31
  ) {}
32
32
 
@@ -59,14 +59,14 @@ export class BashProgram {
59
59
  if (!tree) return new BashProgram(command, [], [], []);
60
60
 
61
61
  try {
62
- const { externalPaths, ruleCandidates } = new BashPathResolver(
62
+ const { externalAccesses, ruleCandidates } = new BashPathResolver(
63
63
  normalizer,
64
64
  options?.workdir,
65
65
  ).resolve(tree.rootNode);
66
66
  return new BashProgram(
67
67
  command,
68
68
  collectCommands(tree.rootNode),
69
- externalPaths,
69
+ externalAccesses,
70
70
  ruleCandidates,
71
71
  );
72
72
  } finally {
@@ -104,21 +104,25 @@ export class BashProgram {
104
104
  }
105
105
 
106
106
  /**
107
- * Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
108
- * objects holding both the lexical (as-typed) and canonical (symlink-resolved)
109
- * forms behind distinct accessors.
107
+ * Deduplicated accesses that resolve outside `cwd`: each an
108
+ * {@link AccessPath} value object holding both the lexical (as-typed) and
109
+ * canonical (symlink-resolved) forms behind distinct accessors, paired with
110
+ * the effect the command stream proved for it.
110
111
  *
111
112
  * Resolved eagerly at parse time through the `PathNormalizer` supplied to
112
113
  * `parse()` (platform + cwd baked in).
113
114
  * Use `.matchValues()` for `external_directory` pattern matching and
114
115
  * `.boundaryValue()` for containment checks; `.value()` for display and logs.
116
+ * Two attributions of the same resolved path fold rather than split, so the
117
+ * entry count is a function of the paths alone (#807).
115
118
  */
116
- externalPaths(): AccessPath[] {
117
- return [...this.resolvedExternalPaths];
119
+ externalAccesses(): BashExternalPath[] {
120
+ return [...this.resolvedExternalAccesses];
118
121
  }
119
122
 
120
123
  /**
121
- * Path-rule candidates paired with their policy lookup values.
124
+ * Path-rule candidates paired with their policy lookup values and the
125
+ * effect the command stream proved for each (#807).
122
126
  *
123
127
  * Resolved eagerly at parse time through the `PathNormalizer` supplied to
124
128
  * `parse()` (platform + cwd baked in).
@@ -0,0 +1,98 @@
1
+ import { redirectDestinationEffect } from "#src/access-intent/bash/command-effects";
2
+ import type { TSNode } from "#src/access-intent/bash/parser";
3
+ import type { TokenEffect } from "#src/access-intent/effect";
4
+
5
+ /**
6
+ * What a redirect node in the parse tree proves.
7
+ *
8
+ * `command-effects.ts` owns the operator *table* — which spelling means read,
9
+ * which means write — and this module owns reading a `file_redirect` node well
10
+ * enough to consult it: finding the operator among the node's children, and
11
+ * telling a destination that names a file from one that names a descriptor.
12
+ *
13
+ * The split exists because two callers need different answers from the same
14
+ * read, and — importantly — they need them under different burdens of proof.
15
+ * The token collector asks what effect to *attribute* to a destination it is
16
+ * about to emit, so it answers with a proof. The command enumerator asks
17
+ * whether it is safe to *remove* the wrapper floor, so it answers with a
18
+ * refusal: anything it cannot resolve counts against the exemption (#803).
19
+ * One reader of the node keeps the two from drifting on what a redirect is.
20
+ */
21
+
22
+ /**
23
+ * The effect `redirect` proves for `destination`, or `null` when the redirect
24
+ * names no file and no token should be collected.
25
+ *
26
+ * `>&` and `<&` are the two operators that may name either a file descriptor
27
+ * (`2>&1`) or a real file (`cmd >& out`); the destination node's type is the
28
+ * parse-tree fact that tells them apart.
29
+ */
30
+ export function redirectEffectForDestination(
31
+ redirect: TSNode,
32
+ destination: TSNode,
33
+ ): TokenEffect | null {
34
+ return redirectDestinationEffect(
35
+ redirectOperatorOf(redirect),
36
+ DESCRIPTOR_NODE_TYPES.has(destination.type),
37
+ );
38
+ }
39
+
40
+ /**
41
+ * True unless `redirect` provably only reads — the fail-closed question the
42
+ * floor exemption asks.
43
+ *
44
+ * Deliberately **not** the negation of a write proof. A destination this module
45
+ * cannot resolve counts as a write here, because the caller is deciding whether
46
+ * to remove a guard: `> $OUT`, `>${OUT}`, and `> $(mktemp)` name a file chosen
47
+ * at run time, and the parse can say nothing about which. Reading those as "no
48
+ * write proved, therefore no write" would hand the exemption to exactly the
49
+ * shapes least visible to every other surface — the path projection does not
50
+ * collect them either (#609).
51
+ *
52
+ * Only two things clear it: a descriptor duplication (`2>&1`), which names no
53
+ * file, and an operator that proves a read — reading a file alongside a pure
54
+ * reader leaves it a pure reader.
55
+ */
56
+ export function redirectMayWriteFile(redirect: TSNode): boolean {
57
+ for (let i = 0; i < redirect.childCount; i++) {
58
+ const child = redirect.child(i);
59
+ // The operator itself is the redirect's only unnamed child.
60
+ if (!child?.isNamed) continue;
61
+ // A source or duplicated descriptor (`2`, `&1`) names no file.
62
+ if (DESCRIPTOR_NODE_TYPES.has(child.type)) continue;
63
+ // A shape the grammar could not resolve (`<>` degrades to one) says nothing
64
+ // about direction, so it cannot clear the check.
65
+ if (child.type === "ERROR") return true;
66
+ if (redirectEffectForDestination(redirect, child)?.effect !== "read") {
67
+ return true;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Destination node types that name a file descriptor rather than a file, so
75
+ * `>&` / `<&` duplicate a stream instead of touching the filesystem.
76
+ *
77
+ * Neither type is in {@link ARG_NODE_TYPES}, so `2>&1`'s `1` is already never
78
+ * collected; the check is what keeps that true if the argument set widens.
79
+ */
80
+ const DESCRIPTOR_NODE_TYPES: ReadonlySet<string> = new Set([
81
+ "file_descriptor",
82
+ "number",
83
+ ]);
84
+
85
+ /**
86
+ * The redirect operator of a redirect node.
87
+ *
88
+ * tree-sitter-bash emits it as an unnamed child whose `type` is the operator
89
+ * text itself, and a redirect's only unnamed child is that operator — so the
90
+ * syntax proof is a lookup on the first one found.
91
+ */
92
+ function redirectOperatorOf(node: TSNode): string {
93
+ for (let i = 0; i < node.childCount; i++) {
94
+ const child = node.child(i);
95
+ if (child && !child.isNamed) return child.type;
96
+ }
97
+ return "";
98
+ }
@@ -16,9 +16,17 @@
16
16
  * `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
17
17
  *
18
18
  * All three classifiers share the private `rejectNonPathToken` predicate that
19
- * captures the six rejection cases common to them (the production clone this
19
+ * captures the five rejection cases common to them (the production clone this
20
20
  * module was extracted to eliminate).
21
21
  *
22
+ * None of them reads a token's glob or regex metacharacters. A shell bracket
23
+ * glob and a regex character class are spelled identically, so "contains a
24
+ * metacharacter" cannot decide path-hood — and it silently dropped tokens the
25
+ * shell really does expand into filesystem paths (`/etc/[p]asswd`,
26
+ * `rm -rf /tmp/tmp.*`, #821). What a pattern argument is instead settled by
27
+ * *position*: `PATTERN_FIRST_COMMANDS` in `token-collection.ts` skips a
28
+ * pattern-first command's inline pattern positional at collection time.
29
+ *
22
30
  * Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
23
31
  * Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
24
32
  * platforms. On POSIX the token resolves as a real in-CWD relative path and is
@@ -106,8 +114,8 @@ export function classifyTokenAsRuleCandidate(
106
114
  * *shape* name a path at all?
107
115
  *
108
116
  * It runs only the shared `rejectNonPathToken` prelude, so a flag,
109
- * env-assignment, URL, `@scope` token, or regex-shaped token is never a
110
- * candidate. Everything else is returned for the caller to probe.
117
+ * env-assignment, URL, or `@scope` token is never a candidate. Everything else
118
+ * is returned for the caller to probe.
111
119
  *
112
120
  * Deliberately consults no policy: candidacy is settled by the filesystem and
113
121
  * the decision by the ruleset, which keeps this module a pure shape function
@@ -137,19 +145,14 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
137
145
  */
138
146
  const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
139
147
 
140
- /**
141
- * Regex metacharacter sequences that are never found in real filesystem paths.
142
- * If a token contains any of these, it is almost certainly a regex pattern
143
- * (e.g. a grep argument) rather than a path.
144
- */
145
- const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
146
-
147
148
  /**
148
149
  * Shared rejection prelude: returns `true` when a token can never be a
149
150
  * filesystem path, regardless of which classifier is asking.
150
151
  *
151
152
  * Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
152
- * URLs, `@scope/package` patterns, and regex metacharacter sequences.
153
+ * URLs, and `@scope/package` patterns. Each rules a token out by syntax; a
154
+ * glob or regex metacharacter does not, since the two are indistinguishable
155
+ * and the shell expands the former into real paths (#821).
153
156
  *
154
157
  * A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root,
155
158
  * a deliberate external-directory access (`find /`, `ls /`), so it must reach
@@ -172,7 +175,5 @@ function rejectNonPathToken(token: string): boolean {
172
175
  // since it looks like an absolute-rooted path, not an npm scope.
173
176
  if (token.startsWith("@") && !token.startsWith("@/")) return true;
174
177
 
175
- if (REGEX_METACHAR_PATTERN.test(token)) return true;
176
-
177
178
  return false;
178
179
  }