@gotgenes/pi-permission-system 11.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +9 -11
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +1 -1
  5. package/src/config-modal.ts +3 -7
  6. package/src/extension-config.ts +11 -25
  7. package/src/handlers/gates/bash-path-extractor.ts +0 -12
  8. package/src/handlers/gates/bash-path.ts +12 -10
  9. package/src/handlers/gates/bash-program.ts +52 -11
  10. package/src/handlers/gates/bash-token-classification.ts +1 -1
  11. package/src/handlers/gates/external-directory.ts +8 -2
  12. package/src/handlers/gates/path.ts +4 -2
  13. package/src/handlers/gates/tool-call-gate-pipeline.ts +5 -2
  14. package/src/index.ts +8 -2
  15. package/src/input-normalizer.ts +17 -11
  16. package/src/path-utils.ts +122 -0
  17. package/src/permission-manager.ts +81 -17
  18. package/src/permission-resolver.ts +24 -0
  19. package/src/permission-session.ts +2 -4
  20. package/src/permissions-service.ts +12 -0
  21. package/src/rule.ts +61 -11
  22. package/src/service.ts +24 -0
  23. package/src/tool-access-extractor-registry.ts +68 -0
  24. package/test/bash-external-directory.test.ts +1 -81
  25. package/test/composition-root.test.ts +36 -0
  26. package/test/config-modal.test.ts +7 -10
  27. package/test/config-pipeline.test.ts +90 -0
  28. package/test/extension-config.test.ts +0 -58
  29. package/test/handlers/gates/bash-path.test.ts +45 -2
  30. package/test/handlers/gates/bash-program.test.ts +44 -11
  31. package/test/handlers/gates/external-directory.test.ts +54 -0
  32. package/test/handlers/gates/path.test.ts +72 -0
  33. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +64 -1
  34. package/test/helpers/gate-fixtures.ts +23 -3
  35. package/test/helpers/handler-fixtures.ts +14 -2
  36. package/test/helpers/session-fixtures.ts +14 -0
  37. package/test/input-normalizer.test.ts +52 -0
  38. package/test/path-utils.test.ts +135 -0
  39. package/test/permission-manager-unified.test.ts +134 -0
  40. package/test/permission-resolver.test.ts +69 -0
  41. package/test/permissions-service.test.ts +35 -1
  42. package/test/rule.test.ts +74 -1
  43. package/test/service-lifecycle.test.ts +1 -0
  44. package/test/service.test.ts +53 -0
  45. package/test/tool-access-extractor-registry.test.ts +77 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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
+
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)
33
+
34
+
35
+ ### ⚠ BREAKING CHANGES
36
+
37
+ * extension and MCP tools that expose a filesystem path (input.path, or input.arguments.path for MCP) are now subject to the path and external_directory permission gates. Tools previously ungated may now prompt or be denied under existing path rules.
38
+
39
+ ### Features
40
+
41
+ * add extensible tool input path extraction ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([3a54ea1](https://github.com/gotgenes/pi-packages/commit/3a54ea16be4d621bd7474f7a728d97ce9781a994))
42
+ * add tool access extractor registry ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([7a34f01](https://github.com/gotgenes/pi-packages/commit/7a34f0187f6b3fbb75e056082f04d3b805a37c8a))
43
+ * expose registerToolAccessExtractor via permissions service ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([5e02c16](https://github.com/gotgenes/pi-packages/commit/5e02c163b212adf9648a2631e4788f030539a36a))
44
+ * gate extension and MCP path tools by default ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([1d53f4f](https://github.com/gotgenes/pi-packages/commit/1d53f4ffa1a08e953b96437e9adf0214c6ca7465))
45
+
46
+
47
+ ### Documentation
48
+
49
+ * document path-aware extension/MCP gating and registerToolAccessExtractor ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([a2f825f](https://github.com/gotgenes/pi-packages/commit/a2f825f031ec26f1c47dbf13d056e724fda87021))
50
+
8
51
  ## [11.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.1...pi-permission-system-v11.0.0) (2026-06-11)
9
52
 
10
53
 
package/README.md CHANGED
@@ -65,11 +65,12 @@ All permissions use one of three states:
65
65
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
66
66
  See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
67
67
 
68
- The `path` surface is a cross-cutting gate that applies to **all** file access — both Pi tools and bash commands.
69
- A `path` deny cannot be overridden by a per-tool allow, making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
68
+ The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
69
+ Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
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": "11.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 path-bearing 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 — both Pi tools and bash commands. A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all 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
  *
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  canonicalNormalizePathForComparison,
3
- getPathBearingToolPath,
3
+ getToolInputPath,
4
4
  isPathOutsideWorkingDirectory,
5
5
  isPiInfrastructureRead,
6
6
  } from "#src/path-utils";
7
7
  import { SessionApproval } from "#src/session-approval";
8
8
  import { deriveApprovalPattern } from "#src/session-rules";
9
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
9
10
  import type { GateResult } from "./descriptor";
10
11
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
11
12
  import type { ToolCallContext } from "./types";
@@ -21,10 +22,15 @@ import type { ToolCallContext } from "./types";
21
22
  export function describeExternalDirectoryGate(
22
23
  tcc: ToolCallContext,
23
24
  infraDirs: string[],
25
+ extractors?: ToolAccessExtractorLookup,
24
26
  ): GateResult {
25
27
  if (!tcc.cwd) return null;
26
28
 
27
- const externalDirectoryPath = getPathBearingToolPath(tcc.toolName, tcc.input);
29
+ const externalDirectoryPath = getToolInputPath(
30
+ tcc.toolName,
31
+ tcc.input,
32
+ extractors,
33
+ );
28
34
  if (!externalDirectoryPath) return null;
29
35
 
30
36
  if (!isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd)) {
@@ -1,7 +1,8 @@
1
- import { getPathBearingToolPath } from "#src/path-utils";
1
+ import { getToolInputPath } from "#src/path-utils";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
5
6
  import type { GateDescriptor, GateResult } from "./descriptor";
6
7
  import type { ToolCallContext } from "./types";
7
8
 
@@ -16,8 +17,9 @@ import type { ToolCallContext } from "./types";
16
17
  export function describePathGate(
17
18
  tcc: ToolCallContext,
18
19
  resolver: ScopedPermissionResolver,
20
+ extractors?: ToolAccessExtractorLookup,
19
21
  ): GateResult {
20
- const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
22
+ const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
21
23
  if (!filePath) return null;
22
24
 
23
25
  const check = resolver.resolve(
@@ -1,6 +1,7 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
+ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
4
5
  import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
5
6
  import {
6
7
  ToolPreviewFormatter,
@@ -53,6 +54,7 @@ export class ToolCallGatePipeline {
53
54
  private readonly resolver: ScopedPermissionResolver,
54
55
  private readonly inputs: ToolCallGateInputs,
55
56
  private readonly customFormatters?: ToolInputFormatterLookup,
57
+ private readonly customExtractors?: ToolAccessExtractorLookup,
56
58
  ) {}
57
59
 
58
60
  async evaluate(
@@ -77,8 +79,9 @@ export class ToolCallGatePipeline {
77
79
  const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
78
80
  () =>
79
81
  describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
80
- () => describePathGate(tcc, this.resolver),
81
- () => describeExternalDirectoryGate(tcc, infraDirs),
82
+ () => describePathGate(tcc, this.resolver, this.customExtractors),
83
+ () =>
84
+ describeExternalDirectoryGate(tcc, infraDirs, this.customExtractors),
82
85
  () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
83
86
  () => describeBashPathGate(tcc, bashProgram, this.resolver),
84
87
  () => {
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import { PermissionSessionLogger } from "./session-logger";
32
32
  import { SessionRules } from "./session-rules";
33
33
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
34
34
  import { getSubagentSessionRegistry } from "./subagent-registry";
35
+ import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
35
36
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
36
37
 
37
38
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
@@ -44,6 +45,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
44
45
  const subagentRegistry = getSubagentSessionRegistry();
45
46
  const formatterRegistry = new ToolInputFormatterRegistry();
46
47
  registerBuiltinToolInputFormatters(formatterRegistry);
48
+ const accessExtractorRegistry = new ToolAccessExtractorRegistry();
47
49
 
48
50
  // Both `configStore` and `session` are forward-declared so the logger's
49
51
  // lazy thunks can close over them without a cast or null-init holder.
@@ -113,8 +115,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
113
115
  registerPermissionSystemCommand(pi, {
114
116
  config: configStore,
115
117
  configPath,
116
- permissionManager,
117
- session,
118
+ getActiveAgentConfigRules: () =>
119
+ permissionManager.getComposedConfigRules(
120
+ session.lastKnownActiveAgentName ?? undefined,
121
+ ),
118
122
  });
119
123
 
120
124
  const rpcHandles = registerPermissionRpcHandlers(pi.events, {
@@ -129,6 +133,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
133
  permissionManager,
130
134
  sessionRules,
131
135
  formatterRegistry,
136
+ accessExtractorRegistry,
132
137
  );
133
138
 
134
139
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
@@ -172,6 +177,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
172
177
  resolver,
173
178
  session,
174
179
  formatterRegistry,
180
+ accessExtractorRegistry,
175
181
  );
176
182
  const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
177
183
  const gates = new PermissionGateHandler(
@@ -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
  }