@gotgenes/pi-permission-system 12.0.0 → 13.0.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,30 @@ 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
+ ## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v12.0.0...pi-permission-system-v13.0.0) (2026-06-12)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * A relative bash path token now also matches absolute allowlist rules naming the same file, resolved against the effective directory after literal cd commands. A token under a config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves from `ask` to `allow`. Tokens after a non-literal cd (e.g. cd "$DIR") stay conservative and match only their literal form.
14
+ * When Pi's working directory is known, a relative path input now also matches absolute allowlist rules naming the same file. A config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves a relative `src/App.jsx` from `ask` to `allow`. To keep tighter control, narrow the allowlist patterns or add an explicit `path` deny for the sensitive paths.
15
+
16
+ ### Features
17
+
18
+ * add alias-aware evaluateAnyValue ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([2b7d240](https://github.com/gotgenes/pi-packages/commit/2b7d24091fbeb078bcfbc363bc0062199ee1de24))
19
+ * add cd-aware pathRuleCandidates to BashProgram ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([102a491](https://github.com/gotgenes/pi-packages/commit/102a491ef73e225a6a93008195ff958e4c5bd315))
20
+ * add path-policy value derivation ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([d34e57f](https://github.com/gotgenes/pi-packages/commit/d34e57fe96c74b2ba87e9d0ebe9be5055db0855f))
21
+ * add resolvePathPolicy resolver method ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ec81da](https://github.com/gotgenes/pi-packages/commit/8ec81da65e7f994beb5f65bead8b11174277fa53))
22
+ * match relative path inputs against absolute allowlists ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([6d0c564](https://github.com/gotgenes/pi-packages/commit/6d0c564d1d7b48227898d0be5fbbf5c91cc7ca89))
23
+ * normalize path inputs to cwd-aware policy values ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([3c2784f](https://github.com/gotgenes/pi-packages/commit/3c2784fc2199b4903a0aaa8a22a971c1bfb4969d))
24
+ * resolve bash path tokens with cd-aware policy values ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([7bcdbe7](https://github.com/gotgenes/pi-packages/commit/7bcdbe708a29448cbfda4a76b7e8917795fbb741))
25
+
26
+
27
+ ### Documentation
28
+
29
+ * document cwd-aware path policy matching ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ab53a2](https://github.com/gotgenes/pi-packages/commit/8ab53a2de6c4deea5bbcd9c72a36ec0943e1a69a))
30
+ * **pi-permission-system:** update Development section to current scripts and tooling ([ebda301](https://github.com/gotgenes/pi-packages/commit/ebda301798290f528f930917b6792a5c21379a5d))
31
+
8
32
  ## [12.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v11.0.0...pi-permission-system-v12.0.0) (2026-06-12)
9
33
 
10
34
 
package/README.md CHANGED
@@ -70,6 +70,7 @@ Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.ar
70
70
 
71
71
  For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
72
72
  This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
73
+ When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
73
74
 
74
75
  Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
75
76
 
@@ -104,19 +105,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
104
105
  ## Development
105
106
 
106
107
  ```bash
107
- pnpm run build # Type-check TypeScript (no emit)
108
- pnpm run lint # Biome lint + format check
109
- pnpm run lint:fix # Biome lint + format auto-fix
110
- pnpm run lint:md # markdownlint-cli2 on README etc.
111
- pnpm run lint:all # lint + lint:md
112
- pnpm run format # Biome format --write
113
- pnpm run test # Run tests from ./tests
114
- pnpm run check # build + lint:all + test
108
+ pnpm run check # Type-check TypeScript (no emit)
109
+ pnpm run lint # Biome + ESLint + lint:md
110
+ pnpm run lint:md # rumdl on README and docs
111
+ pnpm run test # Run tests from ./test
112
+ pnpm run test:watch # Run tests in watch mode
115
113
  ```
116
114
 
117
115
  ### Pre-commit hooks
118
116
 
119
- This project uses [prek](https://prek.j178.dev/) to run Biome and markdownlint on staged files before each commit.
117
+ This project uses [prek](https://prek.j178.dev/) to run Biome, ESLint, and rumdl on staged files before each commit.
120
118
  Run `pnpm install` to set up hooks automatically.
121
119
 
122
120
  ## Acknowledgments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "permission": {
55
55
  "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
56
- "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
56
+ "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
57
57
  "type": "object",
58
58
  "propertyNames": {
59
59
  "description": "A surface name or the universal fallback key '*'.",
@@ -16,10 +16,8 @@ interface PermissionSystemConfigController {
16
16
  config: CommandConfigStore;
17
17
  /** Precomputed global config file path. */
18
18
  configPath: string;
19
- /** Returns the composed config-layer ruleset for origin display. */
20
- permissionManager: { getComposedConfigRules(agentName?: string): Ruleset };
21
- /** Provides the active agent name for scoped rule lookup. */
22
- session: { readonly lastKnownActiveAgentName: string | null };
19
+ /** Returns the composed config-layer ruleset for the active agent scope. */
20
+ getActiveAgentConfigRules(): Ruleset;
23
21
  }
24
22
 
25
23
  const ON_OFF = ["on", "off"];
@@ -206,9 +204,7 @@ function handleArgs(
206
204
  }
207
205
 
208
206
  if (normalized === "show") {
209
- const rules = controller.permissionManager.getComposedConfigRules(
210
- controller.session.lastKnownActiveAgentName ?? undefined,
211
- );
207
+ const rules = controller.getActiveAgentConfigRules();
212
208
  ctx.ui.notify(
213
209
  `permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
214
210
  "info",
@@ -2,11 +2,7 @@ import { mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- import {
6
- normalizeOptionalPositiveInt,
7
- normalizeOptionalStringArray,
8
- toRecord,
9
- } from "./common";
5
+ import type { UnifiedPermissionConfig } from "./config-loader";
10
6
 
11
7
  export const EXTENSION_ID = "pi-permission-system";
12
8
 
@@ -51,31 +47,21 @@ export function detectMisplacedPermissionKeys(
51
47
  }
52
48
 
53
49
  export function normalizePermissionSystemConfig(
54
- raw: unknown,
50
+ raw: UnifiedPermissionConfig,
55
51
  ): PermissionSystemExtensionConfig {
56
- const record = toRecord(raw);
57
- const piInfrastructureReadPaths = normalizeOptionalStringArray(
58
- record.piInfrastructureReadPaths,
59
- );
60
52
  const result: PermissionSystemExtensionConfig = {
61
- debugLog: record.debugLog === true,
62
- permissionReviewLog: record.permissionReviewLog !== false,
63
- yoloMode: record.yoloMode === true,
53
+ debugLog: raw.debugLog === true,
54
+ permissionReviewLog: raw.permissionReviewLog !== false,
55
+ yoloMode: raw.yoloMode === true,
64
56
  };
65
- if (piInfrastructureReadPaths !== undefined) {
66
- result.piInfrastructureReadPaths = piInfrastructureReadPaths;
57
+ if (raw.piInfrastructureReadPaths !== undefined) {
58
+ result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
67
59
  }
68
- const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
69
- record.toolInputPreviewMaxLength,
70
- );
71
- if (toolInputPreviewMaxLength !== undefined) {
72
- result.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
60
+ if (raw.toolInputPreviewMaxLength !== undefined) {
61
+ result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
73
62
  }
74
- const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
75
- record.toolTextSummaryMaxLength,
76
- );
77
- if (toolTextSummaryMaxLength !== undefined) {
78
- result.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
63
+ if (raw.toolTextSummaryMaxLength !== undefined) {
64
+ result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
79
65
  }
80
66
  return result;
81
67
  }
@@ -13,15 +13,3 @@ export async function extractExternalPathsFromBashCommand(
13
13
  ): Promise<string[]> {
14
14
  return (await BashProgram.parse(command)).externalPaths(cwd);
15
15
  }
16
-
17
- /**
18
- * Extract tokens from a bash command that may be file paths, using the broader
19
- * filter suitable for cross-cutting `path` permission rules.
20
- *
21
- * Thin facade over {@link BashProgram.pathTokens}.
22
- */
23
- export async function extractTokensForPathRules(
24
- command: string,
25
- ): Promise<string[]> {
26
- return (await BashProgram.parse(command)).pathTokens();
27
- }
@@ -12,10 +12,12 @@ import type { ToolCallContext } from "./types";
12
12
  /**
13
13
  * Build a pure descriptor for the cross-cutting path permission gate (bash).
14
14
  *
15
- * Reads path-candidate tokens from the injected `BashProgram` (the broader
16
- * `path`-rule filter, accepting dot-files and relative paths). Evaluates each
17
- * token against the `path` permission surface and returns the most
18
- * restrictive result.
15
+ * Reads path-rule candidates from the injected `BashProgram` (the broader
16
+ * `path`-rule filter, accepting dot-files and relative paths). Each candidate
17
+ * pairs the raw token with cd-aware policy values; the gate evaluates those
18
+ * values against the `path` permission surface and returns the most
19
+ * restrictive result, while prompts, logs, and session approvals use the raw
20
+ * token.
19
21
  *
20
22
  * Returns `null` when the gate does not apply (tool is not bash, no command,
21
23
  * no tokens extracted, or all tokens evaluate to `allow`).
@@ -34,18 +36,18 @@ export function describeBashPathGate(
34
36
 
35
37
  if (!bashProgram) return null;
36
38
 
37
- const tokens = bashProgram.pathTokens();
38
- if (tokens.length === 0) return null;
39
+ const candidates = bashProgram.pathRuleCandidates(tcc.cwd);
40
+ if (candidates.length === 0) return null;
41
+ const tokens = candidates.map(({ token }) => token);
39
42
 
40
43
  // Tokens whose resolved state needs a check (deny/ask), paired with the
41
44
  // token that produced them so the descriptor can derive its pattern.
42
45
  const uncovered: Array<{ token: string; check: PermissionCheckResult }> = [];
43
46
  let allSessionCovered = true;
44
47
 
45
- for (const token of tokens) {
46
- const check = resolver.resolve(
47
- "path",
48
- { path: token },
48
+ for (const { token, policyValues } of candidates) {
49
+ const check = resolver.resolvePathPolicy(
50
+ policyValues,
49
51
  tcc.agentName ?? undefined,
50
52
  );
51
53
 
@@ -6,9 +6,11 @@ import {
6
6
  classifyTokenAsRuleCandidate,
7
7
  } from "#src/handlers/gates/bash-token-classification";
8
8
  import {
9
+ getPathPolicyValues,
9
10
  isPathWithinDirectory,
10
11
  isSafeSystemPath,
11
12
  normalizePathForComparison,
13
+ normalizePathPolicyLiteral,
12
14
  } from "#src/path-utils";
13
15
  import type { BashCommandContext } from "#src/types";
14
16
 
@@ -98,6 +100,13 @@ interface PathCandidate {
98
100
  readonly base: EffectiveBase;
99
101
  }
100
102
 
103
+ export interface BashPathRuleCandidate {
104
+ /** Raw path-like token shown in prompts, logs, and session approvals. */
105
+ readonly token: string;
106
+ /** Equivalent values used for permission policy matching. */
107
+ readonly policyValues: readonly string[];
108
+ }
109
+
101
110
  /**
102
111
  * A bash command parsed once into a reusable representation.
103
112
  *
@@ -139,23 +148,36 @@ export class BashProgram {
139
148
  }
140
149
 
141
150
  /**
142
- * Tokens that may be file paths, using the broader `path`-rule filter.
151
+ * Path-rule candidates paired with their policy lookup values.
143
152
  *
144
- * Accepts relative paths (`.env`, `src/foo.ts`, `./build`) and absolute
145
- * paths; does NOT filter by CWD. Returns deduplicated tokens for rule
146
- * evaluation.
153
+ * When `cwd` is available, each relative token is resolved against the
154
+ * effective working directory in force at the token's position (folding
155
+ * literal current-shell `cd` commands), while raw and project-relative
156
+ * aliases are retained for backward-compatible relative rules. A token after
157
+ * a non-literal `cd` keeps only its literal value so no spurious absolute
158
+ * rule can match.
147
159
  */
148
- pathTokens(): string[] {
160
+ pathRuleCandidates(cwd?: string): BashPathRuleCandidate[] {
149
161
  const seen = new Set<string>();
150
- const result: string[] = [];
151
- for (const { token } of this.rawCandidates) {
162
+ const result: BashPathRuleCandidate[] = [];
163
+
164
+ for (const { token, base } of this.rawCandidates) {
152
165
  const candidate = classifyTokenAsRuleCandidate(token);
153
166
  if (!candidate) continue;
154
- if (!seen.has(candidate)) {
155
- seen.add(candidate);
156
- result.push(candidate);
157
- }
167
+
168
+ const policyValues = getPolicyValuesForRuleCandidate(
169
+ candidate,
170
+ base,
171
+ cwd,
172
+ );
173
+ if (policyValues.length === 0) continue;
174
+
175
+ const key = policyValues.join("\0");
176
+ if (seen.has(key)) continue;
177
+ seen.add(key);
178
+ result.push({ token: candidate, policyValues });
158
179
  }
180
+
159
181
  return result;
160
182
  }
161
183
 
@@ -894,6 +916,25 @@ function isRelativeCandidate(candidate: string): boolean {
894
916
  return !candidate.startsWith("/") && !candidate.startsWith("~");
895
917
  }
896
918
 
919
+ function getPolicyValuesForRuleCandidate(
920
+ candidate: string,
921
+ base: EffectiveBase,
922
+ cwd: string | undefined,
923
+ ): string[] {
924
+ if (!cwd) {
925
+ const literal = normalizePathPolicyLiteral(candidate);
926
+ return literal ? [literal] : [];
927
+ }
928
+
929
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
930
+ const literal = normalizePathPolicyLiteral(candidate);
931
+ return literal ? [literal] : [];
932
+ }
933
+
934
+ const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
935
+ return getPathPolicyValues(candidate, { cwd, resolveBase });
936
+ }
937
+
897
938
  /**
898
939
  * Compute the effective base after a command runs. Returns `base` unchanged
899
940
  * unless the command is `cd`:
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure, synchronous token-classification helpers for bash path extraction.
3
3
  *
4
- * Exports two classifiers consumed by `bash-path-extractor.ts`:
4
+ * Exports two classifiers consumed by `bash-program.ts`:
5
5
  * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
6
6
  * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
7
7
  *
package/src/index.ts CHANGED
@@ -115,8 +115,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
115
115
  registerPermissionSystemCommand(pi, {
116
116
  config: configStore,
117
117
  configPath,
118
- permissionManager,
119
- session,
118
+ getActiveAgentConfigRules: () =>
119
+ permissionManager.getComposedConfigRules(
120
+ session.lastKnownActiveAgentName ?? undefined,
121
+ ),
120
122
  });
121
123
 
122
124
  const rpcHandles = registerPermissionRpcHandlers(pi.events, {
@@ -1,7 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
- import { expandHomePath } from "./expand-home";
3
2
  import { createMcpPermissionTargets } from "./mcp-targets";
4
- import { PATH_BEARING_TOOLS } from "./path-utils";
3
+ import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
5
4
 
6
5
  /**
7
6
  * Construct a surface-appropriate input object from a raw value string.
@@ -66,12 +65,13 @@ export function normalizeInput(
66
65
  toolName: string,
67
66
  input: unknown,
68
67
  configuredMcpServerNames: readonly string[],
68
+ cwd?: string,
69
69
  ): NormalizedInput {
70
70
  // --- Special surfaces (path, external_directory) ---
71
71
  if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
72
72
  return {
73
73
  surface: toolName,
74
- values: [normalizePathSurfaceValue(input)],
74
+ values: normalizePathSurfaceValues(input, cwd),
75
75
  resultExtras: {},
76
76
  };
77
77
  }
@@ -117,7 +117,7 @@ export function normalizeInput(
117
117
  if (PATH_BEARING_TOOLS.has(toolName)) {
118
118
  return {
119
119
  surface: toolName,
120
- values: [normalizePathSurfaceValue(input)],
120
+ values: normalizePathSurfaceValues(input, cwd),
121
121
  resultExtras: {},
122
122
  };
123
123
  }
@@ -131,15 +131,21 @@ export function normalizeInput(
131
131
  }
132
132
 
133
133
  /**
134
- * Extract and home-expand the `input.path` lookup value shared by every path
135
- * surface (`path`, `external_directory`, and the path-bearing tools).
134
+ * Extract and normalize the path lookup values shared by every path surface
135
+ * (`path`, `external_directory`, and the path-bearing tools).
136
136
  *
137
137
  * Missing, empty, or whitespace-only paths collapse to the surface catch-all
138
- * `"*"`; otherwise `~/…` and `$HOME/…` prefixes are expanded to the OS home
139
- * directory so values match home-anchored patterns symmetrically with how
140
- * `compileWildcardPattern` expands the patterns themselves (#350).
138
+ * `"*"`. When CWD is known, a relative path also produces a normalized
139
+ * absolute policy value and a project-relative alias while keeping its legacy
140
+ * relative value, so values match home- and cwd-anchored patterns
141
+ * symmetrically with how the patterns themselves are expanded (#350).
142
+ *
143
+ * Only `input.path` is read — policy values are never sourced from any other
144
+ * (potentially attacker-controlled) field on the raw tool input.
141
145
  */
142
- function normalizePathSurfaceValue(input: unknown): string {
146
+ function normalizePathSurfaceValues(input: unknown, cwd?: string): string[] {
143
147
  const path = getNonEmptyString(toRecord(input).path);
144
- return path === null ? "*" : expandHomePath(path);
148
+ if (path === null) return ["*"];
149
+ const values = getPathPolicyValues(path, cwd ? { cwd } : {});
150
+ return values.length > 0 ? values : ["*"];
145
151
  }
package/src/path-utils.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  join,
3
3
  normalize,
4
4
  posix as posixPath,
5
+ relative,
5
6
  resolve,
6
7
  win32 as winPath,
7
8
  } from "node:path";
@@ -63,6 +64,86 @@ export function isPathWithinDirectory(
63
64
  );
64
65
  }
65
66
 
67
+ export interface PathPolicyValueOptions {
68
+ /**
69
+ * Current Pi working directory. When provided, returned values include a
70
+ * project-relative alias for paths that resolve inside this directory.
71
+ */
72
+ cwd?: string;
73
+ /**
74
+ * Directory used to resolve `pathValue` into an absolute policy value.
75
+ * Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
76
+ */
77
+ resolveBase?: string;
78
+ }
79
+
80
+ /**
81
+ * Normalize a single path-like lookup value without resolving it against CWD.
82
+ *
83
+ * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
84
+ * while applying the same lexical cleanup as
85
+ * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
86
+ * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
87
+ */
88
+ export function normalizePathPolicyLiteral(pathValue: string): string {
89
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
90
+ if (!trimmed) return "";
91
+ const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
92
+ return expandHomePath(unprefixed);
93
+ }
94
+
95
+ /**
96
+ * Return equivalent lookup values for path-policy matching.
97
+ *
98
+ * The first value is the cwd/effective-base normalized absolute path when a
99
+ * base is available. The later values preserve project-relative and raw
100
+ * relative forms so existing rules like `src/*` and `*.env` continue to match.
101
+ */
102
+ export function getPathPolicyValues(
103
+ pathValue: string,
104
+ options: PathPolicyValueOptions = {},
105
+ ): string[] {
106
+ const literal = normalizePathPolicyLiteral(pathValue);
107
+ if (!literal) return [];
108
+ if (literal === "*") return ["*"];
109
+
110
+ return [
111
+ ...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
112
+ ];
113
+ }
114
+
115
+ function getAbsolutePathPolicyValues(
116
+ pathValue: string,
117
+ options: PathPolicyValueOptions,
118
+ ): string[] {
119
+ const resolveBase = options.resolveBase ?? options.cwd;
120
+ if (!resolveBase) return [];
121
+
122
+ const absolute = normalizePathForComparison(pathValue, resolveBase);
123
+ if (!absolute) return [];
124
+
125
+ return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
126
+ }
127
+
128
+ function getCwdRelativePathPolicyValues(
129
+ absolute: string,
130
+ cwd: string | undefined,
131
+ ): string[] {
132
+ if (!cwd) return [];
133
+
134
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
135
+ if (!normalizedCwd) return [];
136
+ if (
137
+ absolute !== normalizedCwd &&
138
+ !isPathWithinDirectory(absolute, normalizedCwd)
139
+ ) {
140
+ return [];
141
+ }
142
+
143
+ const relativeValue = relative(normalizedCwd, absolute);
144
+ return relativeValue ? [relativeValue] : [];
145
+ }
146
+
66
147
  /**
67
148
  * Paths that are universally safe and should never trigger external-directory checks.
68
149
  * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
3
3
  import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
4
4
  import { normalizeInput } from "./input-normalizer";
5
5
  import { normalizeFlatConfig } from "./normalize";
6
+ import { PATH_SURFACES } from "./path-utils";
6
7
  import {
7
8
  FilePolicyLoader,
8
9
  type PolicyLoader,
@@ -10,7 +11,7 @@ import {
10
11
  type ResolvedPolicyPaths,
11
12
  } from "./policy-loader";
12
13
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
13
- import { evaluate, evaluateFirst } from "./rule";
14
+ import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
14
15
  import { mergeScopesWithOrigins } from "./scope-merge";
15
16
  import {
16
17
  composeRuleset,
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
63
64
  agentName?: string,
64
65
  sessionRules?: Ruleset,
65
66
  ): PermissionCheckResult;
67
+ /**
68
+ * Evaluate the cross-cutting `path` surface against a caller-supplied set of
69
+ * equivalent policy values (e.g. bash tokens already resolved against a
70
+ * preceding literal `cd`). The values are trusted because they are computed
71
+ * internally, never read from a field on raw tool input.
72
+ */
73
+ checkPathPolicy(
74
+ values: readonly string[],
75
+ agentName?: string,
76
+ sessionRules?: Ruleset,
77
+ ): PermissionCheckResult;
66
78
  getToolPermission(toolName: string, agentName?: string): PermissionState;
67
79
  getConfigIssues(agentName?: string): string[];
68
80
  getPolicyCacheStamp(agentName?: string): string;
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
79
91
 
80
92
  export class PermissionManager implements ScopedPermissionManager {
81
93
  private readonly agentDir: string | undefined;
94
+ private currentCwd: string | undefined;
82
95
  private loader: PolicyLoader;
83
96
  private readonly resolvedPermissionsCache = new Map<
84
97
  string,
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
104
117
  * built with explicit paths), only the cache is cleared.
105
118
  */
106
119
  configureForCwd(cwd: string | undefined | null): void {
120
+ this.currentCwd =
121
+ typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
107
122
  if (this.agentDir !== undefined) {
108
123
  this.loader = new FilePolicyLoader(
109
124
  derivePolicyLoaderOptions(this.agentDir, cwd),
@@ -245,29 +260,78 @@ export class PermissionManager implements ScopedPermissionManager {
245
260
  normalizedToolName,
246
261
  input,
247
262
  this.loader.getConfiguredMcpServerNames(),
263
+ this.currentCwd,
248
264
  );
249
265
 
250
- const { rule, value } = evaluateFirst(surface, values, fullRules);
266
+ return buildCheckResult(
267
+ surface,
268
+ values,
269
+ resultExtras,
270
+ normalizedToolName,
271
+ toolName,
272
+ fullRules,
273
+ );
274
+ }
251
275
 
252
- // For MCP, replace the normalizer's fallback target with the actual
253
- // matched candidate value so PermissionCheckResult.target is accurate.
254
- const extras =
255
- surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
276
+ checkPathPolicy(
277
+ values: readonly string[],
278
+ agentName?: string,
279
+ sessionRules?: Ruleset,
280
+ ): PermissionCheckResult {
281
+ const { composedRules } = this.resolvePermissions(agentName);
282
+ const fullRules: Ruleset = sessionRules?.length
283
+ ? [...composedRules, ...sessionRules]
284
+ : composedRules;
256
285
 
257
- return {
258
- toolName,
259
- state: rule.action,
260
- matchedPattern:
261
- rule.layer === "config" || rule.layer === "session"
262
- ? rule.pattern
263
- : undefined,
264
- source: deriveSource(rule, normalizedToolName),
265
- origin: rule.origin,
266
- ...extras,
267
- };
286
+ const lookupValues = values.length > 0 ? [...values] : ["*"];
287
+ return buildCheckResult(
288
+ "path",
289
+ lookupValues,
290
+ {},
291
+ "path",
292
+ "path",
293
+ fullRules,
294
+ );
268
295
  }
269
296
  }
270
297
 
298
+ /**
299
+ * Evaluate a normalized surface/values triple and shape the result.
300
+ *
301
+ * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
302
+ * aliases); every other surface keeps {@link evaluateFirst}. Shared by
303
+ * `checkPermission` and `checkPathPolicy`.
304
+ */
305
+ function buildCheckResult(
306
+ surface: string,
307
+ values: string[],
308
+ resultExtras: Record<string, unknown>,
309
+ normalizedToolName: string,
310
+ toolName: string,
311
+ fullRules: Ruleset,
312
+ ): PermissionCheckResult {
313
+ const { rule, value } = PATH_SURFACES.has(surface)
314
+ ? evaluateAnyValue(surface, values, fullRules)
315
+ : evaluateFirst(surface, values, fullRules);
316
+
317
+ // For MCP, replace the normalizer's fallback target with the actual
318
+ // matched candidate value so PermissionCheckResult.target is accurate.
319
+ const extras =
320
+ surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
321
+
322
+ return {
323
+ toolName,
324
+ state: rule.action,
325
+ matchedPattern:
326
+ rule.layer === "config" || rule.layer === "session"
327
+ ? rule.pattern
328
+ : undefined,
329
+ source: deriveSource(rule, normalizedToolName),
330
+ origin: rule.origin,
331
+ ...extras,
332
+ };
333
+ }
334
+
271
335
  /**
272
336
  * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
273
337
  * Setting agentsDir explicitly from agentDir removes the hidden