@gotgenes/pi-permission-system 18.0.2 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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
+ ## [18.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.2...pi-permission-system-v18.1.0) (2026-07-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add rule-driven bare-token classifier ([0d693a7](https://github.com/gotgenes/pi-packages/commit/0d693a79478b3e7d9d23c573ec5a085ce8afccfd)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
14
+ * **pi-permission-system:** derive promotable path-token matcher from config ([4a29882](https://github.com/gotgenes/pi-packages/commit/4a298827768fe8730b6b51898740d084676d46cd)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
15
+ * **pi-permission-system:** gate bash bare filenames via path rules ([4ee201a](https://github.com/gotgenes/pi-packages/commit/4ee201a0f771c732e9ac62562a09d54204b425aa)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
16
+ * **pi-permission-system:** promote bare tokens in bash path projection ([f887b7c](https://github.com/gotgenes/pi-packages/commit/f887b7cd3820f085d97105bfa6dc470b35500835)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** document bash bare-filename path promotion ([db7159b](https://github.com/gotgenes/pi-packages/commit/db7159b00af083dc9a7af13c41589ba95376f9ac)), closes [#509](https://github.com/gotgenes/pi-packages/issues/509)
22
+
8
23
  ## [18.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.1...pi-permission-system-v18.0.2) (2026-07-01)
9
24
 
10
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "18.0.2",
3
+ "version": "18.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,6 +5,7 @@ import {
5
5
  } from "#src/access-intent/bash/node-text";
6
6
  import type { TSNode } from "#src/access-intent/bash/parser";
7
7
  import {
8
+ classifyPromotedRuleCandidate,
8
9
  classifyTokenAsPathCandidate,
9
10
  classifyTokenAsRuleCandidate,
10
11
  } from "#src/access-intent/bash/token-classification";
@@ -17,6 +18,10 @@ import {
17
18
  import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
18
19
  import type { PathNormalizer } from "#src/path-normalizer";
19
20
  import { isSafeSystemPath } from "#src/safe-system-paths";
21
+ import type { PathRuleTokenMatcher } from "#src/types";
22
+
23
+ /** Default promotion predicate: promotes nothing (#509). */
24
+ const NO_PROMOTION: PathRuleTokenMatcher = () => false;
20
25
 
21
26
  // ── Internal types ───────────────────────────────────────────────────────────
22
27
 
@@ -74,20 +79,29 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
74
79
  /**
75
80
  * Resolves the filesystem paths a parsed bash program references.
76
81
  *
77
- * Holds a {@link PathNormalizer} (platform + cwd baked in) as its collaborator
78
- * and answers all platform/cwd-dependent questions through it — `cd`-base
79
- * folding (`isAbsolute`/`joinBase`), per-candidate resolution (`forPath`/
80
- * `forLiteral`/`resolveBase`), and the outside-cwd boundary decision — so no
81
- * walk step re-reads the platform or threads the cwd.
82
+ * Holds a {@link PathNormalizer} (platform + cwd baked in) as its primary
83
+ * collaborator and answers all platform/cwd-dependent questions through it —
84
+ * `cd`-base folding (`isAbsolute`/`joinBase`), per-candidate resolution
85
+ * (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
86
+ * decision — so no walk step re-reads the platform or threads the cwd.
87
+ *
88
+ * Also holds an `isPromotablePathToken` predicate (default: promotes nothing)
89
+ * deciding whether a bare token that fails the broad rule-candidate shape gate
90
+ * should still be promoted because it matches an active, specific `path` rule
91
+ * (#509). The resolver never sees the rules themselves — the predicate is
92
+ * built and owned by `PermissionManager.getPromotablePathTokenMatcher`.
82
93
  *
83
94
  * Tell-don't-ask: callers hand it a parsed tree and receive the resolved
84
95
  * {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
85
96
  * the `cd`-folding state, and the intermediate path candidates stay private.
86
97
  * One instance per parse ({@link BashProgram.parse} constructs it with the
87
- * session normalizer).
98
+ * session normalizer and promotion predicate).
88
99
  */
89
100
  export class BashPathResolver {
90
- constructor(private readonly normalizer: PathNormalizer) {}
101
+ constructor(
102
+ private readonly normalizer: PathNormalizer,
103
+ private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
104
+ ) {}
91
105
 
92
106
  /**
93
107
  * Resolve a parsed bash program's path references into its external-path and
@@ -387,8 +401,12 @@ export class BashPathResolver {
387
401
  * policy lookup values.
388
402
  *
389
403
  * Filters candidates through the broad path classifier
390
- * (`classifyTokenAsRuleCandidate`) and pairs each qualifying token with its
391
- * set of policy values (absolute + project-relative + raw).
404
+ * (`classifyTokenAsRuleCandidate`), falling back to the rule-driven promoted
405
+ * classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
406
+ * broad classifier rejects for shape — promoted only when the injected
407
+ * `isPromotablePathToken` predicate matches an active, specific `path` rule.
408
+ * Pairs each qualifying token with its set of policy values (absolute +
409
+ * project-relative + raw).
392
410
  * A token after a non-literal `cd` keeps only its literal value so no
393
411
  * spurious absolute rule can match (#393).
394
412
  */
@@ -399,7 +417,9 @@ export class BashPathResolver {
399
417
  const result: BashPathRuleCandidate[] = [];
400
418
 
401
419
  for (const { token, base } of candidates) {
402
- const candidate = classifyTokenAsRuleCandidate(token);
420
+ const candidate =
421
+ classifyTokenAsRuleCandidate(token) ??
422
+ classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
403
423
  if (!candidate) continue;
404
424
 
405
425
  const path = this.buildRuleCandidatePath(candidate, base);
@@ -9,6 +9,7 @@ import {
9
9
  } from "#src/access-intent/bash/command-enumeration";
10
10
  import { getParser } from "#src/access-intent/bash/parser";
11
11
  import type { PathNormalizer } from "#src/path-normalizer";
12
+ import type { PathRuleTokenMatcher } from "#src/types";
12
13
 
13
14
  export type { BashCommand, BashPathRuleCandidate };
14
15
 
@@ -37,10 +38,17 @@ export class BashProgram {
37
38
  * through the injected {@link PathNormalizer} (platform + cwd baked in).
38
39
  * Heredoc bodies, comments, and other non-argument content are skipped. An
39
40
  * unparseable command yields an empty program.
41
+ *
42
+ * `isPromotablePathToken`, when supplied, promotes a bare filename token
43
+ * (e.g. `id_rsa`) into `pathRuleCandidates()` when it matches an active,
44
+ * specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
45
+ * callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
46
+ * are unaffected.
40
47
  */
41
48
  static async parse(
42
49
  command: string,
43
50
  normalizer: PathNormalizer,
51
+ isPromotablePathToken?: PathRuleTokenMatcher,
44
52
  ): Promise<BashProgram> {
45
53
  const parser = await getParser();
46
54
  const tree = parser.parse(command);
@@ -49,6 +57,7 @@ export class BashProgram {
49
57
  try {
50
58
  const { externalPaths, ruleCandidates } = new BashPathResolver(
51
59
  normalizer,
60
+ isPromotablePathToken,
52
61
  ).resolve(tree.rootNode);
53
62
  return new BashProgram(
54
63
  collectCommands(tree.rootNode),
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * Pure, synchronous token-classification helpers for bash path extraction.
3
3
  *
4
- * Exports two classifiers consumed by `bash-path-resolver.ts`:
4
+ * Exports three classifiers consumed by `bash-path-resolver.ts`:
5
5
  * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
6
6
  * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
7
+ * - `classifyPromotedRuleCandidate` — rule-driven promotion of a bare filename
8
+ * (e.g. `id_rsa`) that `classifyTokenAsRuleCandidate` rejects for shape, but
9
+ * which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
7
10
  *
8
- * Both classifiers share the private `rejectNonPathToken` predicate that captures
9
- * the seven rejection cases common to both (the production clone this module was
10
- * extracted to eliminate).
11
+ * All three classifiers share the private `rejectNonPathToken` predicate that
12
+ * captures the seven rejection cases common to them (the production clone this
13
+ * module was extracted to eliminate).
11
14
  *
12
- * Both classifiers recognize Windows drive-letter absolute paths (`C:/…`, `C:\…`)
13
- * unconditionally on all platforms. On POSIX the token resolves as a real in-CWD
14
- * relative path and is gated by the `path` surface; on Windows the `PathNormalizer`
15
- * routes it through the absolute-path branch. Shape recognition is platform-independent
16
- * string matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
15
+ * Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
16
+ * Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
17
+ * platforms. On POSIX the token resolves as a real in-CWD relative path and is
18
+ * gated by the `path` surface; on Windows the `PathNormalizer` routes it through
19
+ * the absolute-path branch. Shape recognition is platform-independent string
20
+ * matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
17
21
  */
22
+ import type { PathRuleTokenMatcher } from "#src/types";
18
23
 
19
24
  // ── Public classifiers ─────────────────────────────────────────────────────
20
25
 
@@ -70,6 +75,31 @@ export function classifyTokenAsRuleCandidate(token: string): string | null {
70
75
  return null;
71
76
  }
72
77
 
78
+ /**
79
+ * Rule-driven promotion classifier for bare filenames (#509).
80
+ *
81
+ * A bare token (`id_rsa`) has none of the shapes `classifyTokenAsRuleCandidate`
82
+ * accepts, so it is dropped before rule evaluation by default — most bash
83
+ * argument tokens are not file paths (subcommands, branch names, search
84
+ * patterns). This classifier promotes a bare token into the rule-candidate
85
+ * surface only when the caller-supplied `isPromotable` predicate says it
86
+ * matches an active, specific `path` deny/ask rule, closing the bypass without
87
+ * treating every bare argument as a path.
88
+ *
89
+ * Still runs the shared `rejectNonPathToken` prelude first, so a flag,
90
+ * env-assignment, URL, `@scope` token, or regex-shaped token is never
91
+ * promoted even if it happens to match a configured pattern.
92
+ *
93
+ * Returns the raw token string if it qualifies, or `null` to skip.
94
+ */
95
+ export function classifyPromotedRuleCandidate(
96
+ token: string,
97
+ isPromotable: PathRuleTokenMatcher,
98
+ ): string | null {
99
+ if (rejectNonPathToken(token)) return null;
100
+ return isPromotable(token) ? token : null;
101
+ }
102
+
73
103
  // ── Private rejection predicate ────────────────────────────────────────────
74
104
 
75
105
  /**
@@ -10,7 +10,7 @@ import {
10
10
  ToolPreviewFormatter,
11
11
  type ToolPreviewFormatterOptions,
12
12
  } from "#src/tool-preview-formatter";
13
- import type { PermissionCheckResult } from "#src/types";
13
+ import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
14
14
  import { getNonEmptyString, toRecord } from "#src/value-guards";
15
15
  import { resolveBashCommandCheck } from "./bash-command";
16
16
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
@@ -42,6 +42,11 @@ export interface ToolCallGateInputs {
42
42
  getToolPreviewLimits(): ToolPreviewFormatterOptions;
43
43
  /** The session's path normalizer (platform + cwd baked in). */
44
44
  getPathNormalizer(): PathNormalizer;
45
+ /**
46
+ * Predicate deciding whether a bare bash token should be promoted into the
47
+ * `path` rule-candidate surface (#509), scoped to the given agent.
48
+ */
49
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
45
50
  }
46
51
 
47
52
  /**
@@ -73,7 +78,13 @@ export class ToolCallGatePipeline {
73
78
  const normalizer = this.inputs.getPathNormalizer();
74
79
  const bashProgram =
75
80
  tcc.toolName === "bash" && command
76
- ? await BashProgram.parse(command, normalizer)
81
+ ? await BashProgram.parse(
82
+ command,
83
+ normalizer,
84
+ this.inputs.getPromotablePathTokenMatcher(
85
+ tcc.agentName ?? undefined,
86
+ ),
87
+ )
77
88
  : null;
78
89
 
79
90
  const formatter = new ToolPreviewFormatter(
@@ -15,7 +15,12 @@ import {
15
15
  type ResolvedPolicyPaths,
16
16
  } from "./policy-loader";
17
17
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
18
- import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
18
+ import {
19
+ evaluate,
20
+ evaluateAnyValue,
21
+ evaluateFirst,
22
+ pathMatchOptions,
23
+ } from "./rule";
19
24
  import { mergeScopesWithOrigins } from "./scope-merge";
20
25
  import {
21
26
  composeRuleset,
@@ -24,10 +29,12 @@ import {
24
29
  } from "./synthesize";
25
30
  import type {
26
31
  FlatPermissionConfig,
32
+ PathRuleTokenMatcher,
27
33
  PermissionCheckResult,
28
34
  PermissionState,
29
35
  } from "./types";
30
36
  import { isPermissionState } from "./value-guards";
37
+ import { wildcardMatch } from "./wildcard-matcher";
31
38
 
32
39
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
33
40
  "bash",
@@ -43,6 +50,9 @@ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
43
50
  /** Universal fallback when permission["*"] is absent from all scopes. */
44
51
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
45
52
 
53
+ /** Promotion predicate matching no token — the no-`path`-rules default (#509). */
54
+ const NO_PROMOTION: PathRuleTokenMatcher = () => false;
55
+
46
56
  type FileCacheEntry<TValue> = {
47
57
  stamp: string;
48
58
  value: TValue;
@@ -76,6 +86,15 @@ export interface ScopedPermissionManager {
76
86
  ): PermissionCheckResult;
77
87
  getToolPermission(toolName: string, agentName?: string): PermissionState;
78
88
  getConfigIssues(agentName?: string): string[];
89
+ /**
90
+ * Build a predicate deciding whether a bare bash token should be promoted
91
+ * into the `path` rule-candidate surface (#509).
92
+ *
93
+ * Matches against specific (non-`*`) `path`-surface config rules whose
94
+ * action is `deny` or `ask` — an allow rule never gates, and `"*"` would
95
+ * promote every bare bash argument.
96
+ */
97
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
79
98
  }
80
99
 
81
100
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -211,6 +230,37 @@ export class PermissionManager implements ScopedPermissionManager {
211
230
  return composedRules.filter((r) => r.layer === "config");
212
231
  }
213
232
 
233
+ /**
234
+ * Build a predicate deciding whether a bare bash token should be promoted
235
+ * into the `path` rule-candidate surface (#509).
236
+ *
237
+ * Filters the composed config ruleset to specific (non-`*`) `path`-surface
238
+ * deny/ask patterns, then returns a closure matching a token against them
239
+ * with the platform-correct fold (Windows case-and-separator matching, same
240
+ * as {@link pathMatchOptions} applies for evaluation) so promotion agrees
241
+ * with the later `path`-surface decision.
242
+ *
243
+ * Returns a matcher rejecting every token when no such rule exists — the
244
+ * default-config case is unaffected by promotion.
245
+ */
246
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
247
+ const { composedRules } = this.resolvePermissions(agentName);
248
+ const patterns = composedRules
249
+ .filter(
250
+ (r) =>
251
+ r.layer === "config" &&
252
+ r.surface === "path" &&
253
+ r.pattern !== "*" &&
254
+ r.action !== "allow",
255
+ )
256
+ .map((r) => r.pattern);
257
+ if (patterns.length === 0) return NO_PROMOTION;
258
+
259
+ const matchOptions = pathMatchOptions("path", this.platform);
260
+ return (token) =>
261
+ patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
262
+ }
263
+
214
264
  /**
215
265
  * Get the tool-level permission state for a tool, without considering
216
266
  * command-level rules. Used for tool injection decisions.
@@ -19,6 +19,7 @@ import {
19
19
  resolveToolPreviewLimits,
20
20
  type ToolPreviewFormatterOptions,
21
21
  } from "./tool-preview-formatter";
22
+ import type { PathRuleTokenMatcher } from "./types";
22
23
 
23
24
  /**
24
25
  * Encapsulates all mutable session state and exposes operations instead of
@@ -214,4 +215,15 @@ export class PermissionSession implements ToolCallGateInputs {
214
215
  getPathNormalizer(): PathNormalizer {
215
216
  return this.pathNormalizer;
216
217
  }
218
+
219
+ /**
220
+ * Predicate deciding whether a bare bash token should be promoted into the
221
+ * `path` rule-candidate surface (#509), scoped to the given agent.
222
+ *
223
+ * Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
224
+ * the manager owns the composed ruleset and the platform-correct match.
225
+ */
226
+ getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
227
+ return this.permissionManager.getPromotablePathTokenMatcher(agentName);
228
+ }
217
229
  }
package/src/rule.ts CHANGED
@@ -74,7 +74,7 @@ export function evaluate(
74
74
  * pattern→value match (case and separators) so mixed-case / forward-slash
75
75
  * overrides still match. The surface→surface match stays exact.
76
76
  */
77
- function pathMatchOptions(
77
+ export function pathMatchOptions(
78
78
  surface: string,
79
79
  platform: NodeJS.Platform,
80
80
  ): { caseInsensitive: true; windowsSeparators: true } | undefined {
package/src/types.ts CHANGED
@@ -16,6 +16,17 @@ export interface DenyWithReason {
16
16
  /** A pattern value: a PermissionState string OR a DenyWithReason object. */
17
17
  export type PatternValue = PermissionState | DenyWithReason;
18
18
 
19
+ /**
20
+ * Predicate deciding whether a bare bash token should be promoted into the
21
+ * `path` rule-candidate surface.
22
+ *
23
+ * Built by `PermissionManager.getPromotablePathTokenMatcher` from the
24
+ * composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
25
+ * threaded through to `BashPathResolver` so promotion policy stays in the
26
+ * manager while the bash layer only asks the predicate.
27
+ */
28
+ export type PathRuleTokenMatcher = (token: string) => boolean;
29
+
19
30
  /**
20
31
  * The on-disk permission shape inside the `"permission"` key.
21
32
  * A surface value is a PermissionState string (shorthand for `{ "*": action }`)
@@ -91,6 +91,63 @@ describe("BashProgram", () => {
91
91
  expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
92
92
  expect(fileCandidate?.path.boundaryValue()).toBe("");
93
93
  });
94
+
95
+ describe("rule-driven bare-token promotion (#509)", () => {
96
+ it("promotes a bare token when the matcher says it is promotable", async () => {
97
+ const isPromotable = (token: string): boolean => token === "id_rsa";
98
+ const program = await BashProgram.parse(
99
+ "cat id_rsa",
100
+ normalizer,
101
+ isPromotable,
102
+ );
103
+ const candidates = program.pathRuleCandidates();
104
+ expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
105
+ expect(candidates[0].path.matchValues()).toEqual([
106
+ "/projects/my-app/id_rsa",
107
+ "id_rsa",
108
+ ]);
109
+ });
110
+
111
+ it("does not promote a bare token the matcher rejects", async () => {
112
+ const isPromotable = (token: string): boolean => token === "id_rsa";
113
+ const program = await BashProgram.parse(
114
+ "git status",
115
+ normalizer,
116
+ isPromotable,
117
+ );
118
+ expect(program.pathRuleCandidates()).toHaveLength(0);
119
+ });
120
+
121
+ it("does not promote any bare token with the default no-op matcher", async () => {
122
+ const program = await BashProgram.parse("cat id_rsa", normalizer);
123
+ expect(program.pathRuleCandidates()).toHaveLength(0);
124
+ });
125
+
126
+ it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
127
+ const isPromotable = (token: string): boolean => token === "id_rsa";
128
+ const program = await BashProgram.parse(
129
+ 'cd "$DIR" && cat id_rsa',
130
+ normalizer,
131
+ isPromotable,
132
+ );
133
+ const candidate = program
134
+ .pathRuleCandidates()
135
+ .find((c) => c.token === "id_rsa");
136
+ expect(candidate?.path.matchValues()).toEqual(["id_rsa"]);
137
+ });
138
+
139
+ it("does not double-promote a token the shape gate already accepts", async () => {
140
+ // ./id_rsa already passes classifyTokenAsRuleCandidate; the promoted
141
+ // fallback must not run (and must not duplicate the candidate).
142
+ const isPromotable = (): boolean => true;
143
+ const program = await BashProgram.parse(
144
+ "cat ./id_rsa",
145
+ normalizer,
146
+ isPromotable,
147
+ );
148
+ expect(program.pathRuleCandidates()).toHaveLength(1);
149
+ });
150
+ });
94
151
  });
95
152
 
96
153
  describe("externalPaths", () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
 
3
3
  import {
4
+ classifyPromotedRuleCandidate,
4
5
  classifyTokenAsPathCandidate,
5
6
  classifyTokenAsRuleCandidate,
6
7
  } from "#src/access-intent/bash/token-classification";
@@ -314,3 +315,49 @@ describe("classifyTokenAsRuleCandidate", () => {
314
315
  }
315
316
  });
316
317
  });
318
+
319
+ describe("classifyPromotedRuleCandidate", () => {
320
+ test("shape-eligible bare token promoted when predicate returns true", () => {
321
+ expect(classifyPromotedRuleCandidate("id_rsa", () => true)).toBe("id_rsa");
322
+ });
323
+
324
+ test("shape-eligible bare token rejected when predicate returns false", () => {
325
+ expect(classifyPromotedRuleCandidate("id_rsa", () => false)).toBeNull();
326
+ });
327
+
328
+ test("predicate receives the raw token", () => {
329
+ const isPromotable = (token: string): boolean => token === "key.pem";
330
+ expect(classifyPromotedRuleCandidate("key.pem", isPromotable)).toBe(
331
+ "key.pem",
332
+ );
333
+ expect(classifyPromotedRuleCandidate("other.pem", isPromotable)).toBeNull();
334
+ });
335
+
336
+ describe("shared rejection still applies regardless of the predicate", () => {
337
+ test("flag (leading dash) → null", () => {
338
+ expect(classifyPromotedRuleCandidate("-r", () => true)).toBeNull();
339
+ });
340
+
341
+ test("env assignment → null", () => {
342
+ expect(classifyPromotedRuleCandidate("FOO=/bar", () => true)).toBeNull();
343
+ });
344
+
345
+ test("URL → null", () => {
346
+ expect(
347
+ classifyPromotedRuleCandidate("https://example.com", () => true),
348
+ ).toBeNull();
349
+ });
350
+
351
+ test("@scope/package → null", () => {
352
+ expect(classifyPromotedRuleCandidate("@foo/bar", () => true)).toBeNull();
353
+ });
354
+
355
+ test("regex metacharacters → null", () => {
356
+ expect(classifyPromotedRuleCandidate("foo.*", () => true)).toBeNull();
357
+ });
358
+
359
+ test("empty string → null", () => {
360
+ expect(classifyPromotedRuleCandidate("", () => true)).toBeNull();
361
+ });
362
+ });
363
+ });
@@ -501,6 +501,79 @@ describe("service path queries evaluate the supplied path (#503)", () => {
501
501
  });
502
502
  });
503
503
 
504
+ describe("bash bare-filename path gating (#509)", () => {
505
+ // Before #509 a bash bare-filename argument (`cat id_rsa`) bypassed the
506
+ // `path` surface entirely: the broad classifier only accepted tokens
507
+ // starting with `.`, containing `/`, containing `..`, or a Windows
508
+ // drive-letter absolute path. The same file accessed via a prefixed path
509
+ // (`cat ./id_rsa`) or the `read` tool was already gated. Rule-driven
510
+ // promotion closes the gap for a bare token matching a specific, non-`*`
511
+ // `path` deny/ask rule — the literal repro from the issue.
512
+
513
+ async function fireBashToolCall(
514
+ pi: ReturnType<typeof makeFakePi>,
515
+ ctx: unknown,
516
+ command: string,
517
+ ): Promise<{ block?: true; reason?: string }> {
518
+ return (await pi.fire(
519
+ "tool_call",
520
+ { name: "bash", input: { command }, toolCallId: "tc-1" },
521
+ ctx,
522
+ )) as { block?: true; reason?: string };
523
+ }
524
+
525
+ it("denies a bare filename matching a specific path deny rule", async () => {
526
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
527
+ writeGlobalConfig({
528
+ permission: { "*": "allow", path: { id_rsa: "deny" } },
529
+ });
530
+
531
+ const pi = makeFakePi({ events: createEventBus() });
532
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
533
+ const ctx = makeChildCtx(cwd, "bare-token-session-deny");
534
+ await fireSessionStart(pi, ctx);
535
+
536
+ const result = await fireBashToolCall(pi, ctx, "cat id_rsa");
537
+ expect(result.block).toBe(true);
538
+
539
+ rmSync(cwd, { recursive: true, force: true });
540
+ });
541
+
542
+ it("denies a bare filename matching a wildcard path deny rule", async () => {
543
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
544
+ writeGlobalConfig({
545
+ permission: { "*": "allow", path: { "*.pem": "deny" } },
546
+ });
547
+
548
+ const pi = makeFakePi({ events: createEventBus() });
549
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
550
+ const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
551
+ await fireSessionStart(pi, ctx);
552
+
553
+ const result = await fireBashToolCall(pi, ctx, "cat key.pem");
554
+ expect(result.block).toBe(true);
555
+
556
+ rmSync(cwd, { recursive: true, force: true });
557
+ });
558
+
559
+ it("leaves a bare token that does not match any path rule unaffected", async () => {
560
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
561
+ writeGlobalConfig({
562
+ permission: { "*": "allow", path: { id_rsa: "deny" } },
563
+ });
564
+
565
+ const pi = makeFakePi({ events: createEventBus() });
566
+ piPermissionSystemExtension(pi as unknown as ExtensionAPI);
567
+ const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
568
+ await fireSessionStart(pi, ctx);
569
+
570
+ const result = await fireBashToolCall(pi, ctx, "git status");
571
+ expect(result.block).toBeUndefined();
572
+
573
+ rmSync(cwd, { recursive: true, force: true });
574
+ });
575
+ });
576
+
504
577
  describe("multi-instance global service interplay", () => {
505
578
  // The fix (#302) scopes the process-global service slot to the publishing
506
579
  // instance. The parent publishes at its session_start; an in-process child
@@ -186,6 +186,7 @@ describe("ToolCallGatePipeline", () => {
186
186
  expect(mockBashProgramParse).toHaveBeenCalledWith(
187
187
  "echo hello",
188
188
  expect.any(PathNormalizer),
189
+ expect.any(Function),
189
190
  );
190
191
  });
191
192
 
@@ -202,6 +203,31 @@ describe("ToolCallGatePipeline", () => {
202
203
 
203
204
  expect(mockBashProgramParse).not.toHaveBeenCalled();
204
205
  });
206
+
207
+ it("passes the session's promotable path-token matcher into BashProgram.parse (#509)", async () => {
208
+ const resolver = makeResolver(makeCheckResult());
209
+ const isPromotable = vi.fn((token: string) => token === "id_rsa");
210
+ const getPromotablePathTokenMatcher = vi.fn(() => isPromotable);
211
+ const inputs = makeGateInputs({ getPromotablePathTokenMatcher });
212
+ const { runner } = makeGateRunner();
213
+ const pipeline = new ToolCallGatePipeline(resolver, inputs);
214
+
215
+ await pipeline.evaluate(
216
+ makeTcc({
217
+ toolName: "bash",
218
+ input: { command: "cat id_rsa" },
219
+ agentName: "my-agent",
220
+ }),
221
+ runner,
222
+ );
223
+
224
+ expect(getPromotablePathTokenMatcher).toHaveBeenCalledWith("my-agent");
225
+ expect(mockBashProgramParse).toHaveBeenCalledWith(
226
+ "cat id_rsa",
227
+ expect.any(PathNormalizer),
228
+ isPromotable,
229
+ );
230
+ });
205
231
  });
206
232
 
207
233
  // ── customExtractors threading (#352) ────────────────────────────────────
@@ -15,7 +15,7 @@ import type { ScopedPermissionResolver } from "#src/permission-resolver";
15
15
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
16
16
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
17
17
  import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
18
- import type { PermissionCheckResult } from "#src/types";
18
+ import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
19
19
 
20
20
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
21
21
 
@@ -255,6 +255,9 @@ export function makeGateInputs(
255
255
  getInfrastructureReadDirs?: () => string[];
256
256
  getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
257
257
  getPathNormalizer?: () => PathNormalizer;
258
+ getPromotablePathTokenMatcher?: (
259
+ agentName?: string,
260
+ ) => PathRuleTokenMatcher;
258
261
  } = {},
259
262
  ): ToolCallGateInputs {
260
263
  return {
@@ -275,6 +278,9 @@ export function makeGateInputs(
275
278
  vi.fn<() => PathNormalizer>(
276
279
  () => new PathNormalizer(process.platform, "/test/cwd"),
277
280
  ),
281
+ getPromotablePathTokenMatcher:
282
+ overrides.getPromotablePathTokenMatcher ??
283
+ vi.fn<(agentName?: string) => PathRuleTokenMatcher>(() => () => false),
278
284
  };
279
285
  }
280
286
 
@@ -25,7 +25,11 @@ import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
25
25
  import type { Ruleset } from "#src/rule";
26
26
  import type { SessionLogger } from "#src/session-logger";
27
27
  import { SessionRules } from "#src/session-rules";
28
- import type { PermissionCheckResult, PermissionState } from "#src/types";
28
+ import type {
29
+ PathRuleTokenMatcher,
30
+ PermissionCheckResult,
31
+ PermissionState,
32
+ } from "#src/types";
29
33
 
30
34
  // ── Per-collaborator fake factories ────────────────────────────────────────
31
35
 
@@ -105,6 +109,9 @@ export function makeFakePermissionManager() {
105
109
  .fn<(toolName: string, agentName?: string) => PermissionState>()
106
110
  .mockReturnValue("allow"),
107
111
  getConfigIssues: vi.fn((): string[] => []),
112
+ getPromotablePathTokenMatcher: vi
113
+ .fn<(agentName?: string) => PathRuleTokenMatcher>()
114
+ .mockReturnValue(() => false),
108
115
  };
109
116
  }
110
117
 
@@ -1096,6 +1096,87 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
1096
1096
  ).toBe(true);
1097
1097
  });
1098
1098
  });
1099
+
1100
+ describe("getPromotablePathTokenMatcher (#509)", () => {
1101
+ it("matches a bare token against a specific deny pattern", () => {
1102
+ const manager = makeInMemoryManager({
1103
+ global: { permission: { path: { id_rsa: "deny" } } },
1104
+ });
1105
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1106
+ expect(isPromotable("id_rsa")).toBe(true);
1107
+ expect(isPromotable("other_file")).toBe(false);
1108
+ });
1109
+
1110
+ it("matches a bare token against a specific ask wildcard pattern", () => {
1111
+ const manager = makeInMemoryManager({
1112
+ global: { permission: { path: { "*.pem": "ask" } } },
1113
+ });
1114
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1115
+ expect(isPromotable("key.pem")).toBe(true);
1116
+ expect(isPromotable("key.txt")).toBe(false);
1117
+ });
1118
+
1119
+ it("does not match against the universal '*' path pattern", () => {
1120
+ const manager = makeInMemoryManager({
1121
+ global: { permission: { path: { "*": "ask" } } },
1122
+ });
1123
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1124
+ expect(isPromotable("anything")).toBe(false);
1125
+ expect(isPromotable("status")).toBe(false);
1126
+ });
1127
+
1128
+ it("does not match against an allow-only path pattern", () => {
1129
+ const manager = makeInMemoryManager({
1130
+ global: { permission: { path: { id_rsa: "allow" } } },
1131
+ });
1132
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1133
+ expect(isPromotable("id_rsa")).toBe(false);
1134
+ });
1135
+
1136
+ it("returns a no-op matcher when no path rules exist", () => {
1137
+ const manager = makeInMemoryManager({
1138
+ global: { permission: { read: "allow" } },
1139
+ });
1140
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1141
+ expect(isPromotable("id_rsa")).toBe(false);
1142
+ expect(isPromotable("anything")).toBe(false);
1143
+ });
1144
+
1145
+ it("folds case on an injected win32 platform", () => {
1146
+ const manager = new PermissionManager({
1147
+ policyLoader: createInMemoryPolicyLoader({
1148
+ global: { permission: { path: { id_rsa: "deny" } } },
1149
+ }),
1150
+ platform: "win32",
1151
+ });
1152
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1153
+ expect(isPromotable("ID_RSA")).toBe(true);
1154
+ });
1155
+
1156
+ it("stays case-sensitive on a POSIX platform", () => {
1157
+ const manager = new PermissionManager({
1158
+ policyLoader: createInMemoryPolicyLoader({
1159
+ global: { permission: { path: { id_rsa: "deny" } } },
1160
+ }),
1161
+ platform: "linux",
1162
+ });
1163
+ const isPromotable = manager.getPromotablePathTokenMatcher();
1164
+ expect(isPromotable("ID_RSA")).toBe(false);
1165
+ });
1166
+
1167
+ it("scopes to the requested agent's composed rules", () => {
1168
+ const manager = makeInMemoryManager({
1169
+ global: { permission: { path: { id_rsa: "deny" } } },
1170
+ agent: {
1171
+ coder: { permission: { path: { "secret.key": "deny" } } },
1172
+ },
1173
+ });
1174
+ const globalMatcher = manager.getPromotablePathTokenMatcher();
1175
+ const agentMatcher = manager.getPromotablePathTokenMatcher("coder");
1176
+ expect(globalMatcher("secret.key")).toBe(false);
1177
+ expect(agentMatcher("secret.key")).toBe(true);
1178
+ });
1179
+ });
1099
1180
  });
1100
1181
 
1101
1182
  // ---------------------------------------------------------------------------
@@ -6,7 +6,11 @@ import { PermissionResolver } from "#src/permission-resolver";
6
6
  import type { Ruleset } from "#src/rule";
7
7
  import { SessionApproval } from "#src/session-approval";
8
8
  import { SessionRules } from "#src/session-rules";
9
- import type { PermissionCheckResult, PermissionState } from "#src/types";
9
+ import type {
10
+ PathRuleTokenMatcher,
11
+ PermissionCheckResult,
12
+ PermissionState,
13
+ } from "#src/types";
10
14
 
11
15
  function makePermissionManager() {
12
16
  return {
@@ -28,6 +32,9 @@ function makePermissionManager() {
28
32
  .fn<(toolName: string, agentName?: string) => PermissionState>()
29
33
  .mockReturnValue("allow"),
30
34
  getConfigIssues: vi.fn((): string[] => []),
35
+ getPromotablePathTokenMatcher: vi
36
+ .fn<(agentName?: string) => PathRuleTokenMatcher>()
37
+ .mockReturnValue(() => false),
31
38
  };
32
39
  }
33
40