@gotgenes/pi-permission-system 5.14.1 → 5.16.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,33 @@ 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
+ ## [5.16.0](https://github.com/gotgenes/pi-permission-system/compare/v5.15.0...v5.16.0) (2026-05-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * decision events include file path for path-bearing tools ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([eea226d](https://github.com/gotgenes/pi-permission-system/commit/eea226d990d4358983cc319d54b7544849b2b453))
14
+ * normalizeInput returns file path for path-bearing tools ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([0b48995](https://github.com/gotgenes/pi-permission-system/commit/0b4899563ed3aaf2dd264650a98964168e7ecba1))
15
+ * path-scoped session approvals for path-bearing tools ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([1feacc5](https://github.com/gotgenes/pi-permission-system/commit/1feacc53d4ac1653bf974886ce77e13aff014b68))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * document per-tool path patterns ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([81245f6](https://github.com/gotgenes/pi-permission-system/commit/81245f6ac98500e6c4d3c2191ceb2db032284f05))
21
+ * plan per-tool path patterns for path-bearing tools ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([9458706](https://github.com/gotgenes/pi-permission-system/commit/9458706d85e4711b73677ee1abcc8f3ad7d18a2e))
22
+
23
+ ## [5.15.0](https://github.com/gotgenes/pi-permission-system/compare/v5.14.1...v5.15.0) (2026-05-13)
24
+
25
+
26
+ ### Features
27
+
28
+ * add PI_SUBAGENT_PARENT_SESSION convention for parent session resolution ([3829195](https://github.com/gotgenes/pi-permission-system/commit/3829195c7de1f755adf9aa35809de699434d9aae)), closes [#143](https://github.com/gotgenes/pi-permission-system/issues/143)
29
+
30
+
31
+ ### Documentation
32
+
33
+ * add Cross-Extension Integration section to AGENTS.md ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([90209f7](https://github.com/gotgenes/pi-permission-system/commit/90209f75ecd0845b6ac13c6c4895da32a4c82909))
34
+
8
35
  ## [5.14.1](https://github.com/gotgenes/pi-permission-system/compare/v5.14.0...v5.14.1) (2026-05-11)
9
36
 
10
37
 
package/README.md CHANGED
@@ -34,6 +34,12 @@ pi install npm:@gotgenes/pi-permission-system
34
34
  {
35
35
  "permission": {
36
36
  "*": "allow",
37
+ "read": {
38
+ "*": "allow",
39
+ "*.env": "deny",
40
+ "*.env.*": "deny",
41
+ "*.env.example": "allow"
42
+ },
37
43
  "bash": {
38
44
  "rm -rf *": "deny",
39
45
  "sudo *": "ask"
@@ -56,6 +62,9 @@ All permissions use one of three states:
56
62
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
57
63
  See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
58
64
 
65
+ For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns in the permission map are matched against the file path from `input.path`.
66
+ This lets you express rules like "allow reads but deny `.env` files" — see the example config above.
67
+
59
68
  ## Configuration
60
69
 
61
70
  Config lives in one JSON file per scope:
@@ -9,7 +9,12 @@
9
9
 
10
10
  "permission": {
11
11
  "*": "ask",
12
- "read": "allow",
12
+ "read": {
13
+ "*": "allow",
14
+ "*.env": "deny",
15
+ "*.env.*": "deny",
16
+ "*.env.example": "allow"
17
+ },
13
18
  "write": "deny",
14
19
  "bash": {
15
20
  "*": "ask",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.14.1",
3
+ "version": "5.16.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "permission": {
43
43
  "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
44
- "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`, 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\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
44
+ "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`, 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\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
45
45
  "type": "object",
46
46
  "propertyNames": {
47
47
  "description": "A surface name or the universal fallback key '*'.",
@@ -63,7 +63,12 @@
63
63
  "examples": [
64
64
  {
65
65
  "*": "ask",
66
- "read": "allow",
66
+ "read": {
67
+ "*": "allow",
68
+ "*.env": "deny",
69
+ "*.env.*": "deny",
70
+ "*.env.example": "allow"
71
+ },
67
72
  "write": "deny",
68
73
  "bash": {
69
74
  "*": "ask",
@@ -113,8 +113,9 @@ export async function waitForForwardedPermissionApproval(
113
113
  deps.logger,
114
114
  `Permission forwarding target session could not be resolved. ` +
115
115
  `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
116
- `If you are using nicobailon/pi-subagents or HazAT/pi-interactive-subagents, ` +
117
- `parent-session forwarding is not yet supported for those extensions (see issue #98).`,
116
+ `If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
117
+ `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
118
+ `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
118
119
  );
119
120
  return { approved: false, state: "denied" };
120
121
  }
@@ -3,14 +3,17 @@ import type { PermissionCheckResult } from "../../types";
3
3
 
4
4
  /**
5
5
  * Derive the human-readable value for a decision event from a check result.
6
- * Bash → extracted command; MCP → qualified target; others → tool name.
6
+ * Bash → extracted command; MCP → qualified target;
7
+ * path-bearing tools → file path; others → tool name.
7
8
  */
8
9
  export function deriveDecisionValue(
9
10
  toolName: string,
10
11
  check: Pick<PermissionCheckResult, "command" | "target">,
12
+ path?: string,
11
13
  ): string {
12
14
  if (toolName === "bash") return check.command ?? toolName;
13
15
  if (toolName === "mcp") return check.target ?? toolName;
16
+ if (path) return path;
14
17
  return toolName;
15
18
  }
16
19
 
@@ -1,4 +1,4 @@
1
- import { PATH_BEARING_TOOLS } from "../../path-utils";
1
+ import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "../../path-utils";
2
2
  import { suggestSessionPattern } from "../../pattern-suggest";
3
3
  import {
4
4
  formatAskPrompt,
@@ -11,6 +11,21 @@ import type { GateDescriptor } from "./descriptor";
11
11
  import { deriveDecisionValue } from "./helpers";
12
12
  import type { ToolCallContext } from "./types";
13
13
 
14
+ /**
15
+ * Derive the value used for session-approval pattern suggestions.
16
+ *
17
+ * Bash → command string; MCP → qualified target;
18
+ * path-bearing tools → file path; others → catch-all wildcard.
19
+ */
20
+ function deriveSuggestionValue(
21
+ tcc: ToolCallContext,
22
+ check: PermissionCheckResult,
23
+ ): string {
24
+ if (tcc.toolName === "bash") return check.command ?? "";
25
+ if (tcc.toolName === "mcp") return check.target ?? "mcp";
26
+ return getPathBearingToolPath(tcc.toolName, tcc.input) ?? "*";
27
+ }
28
+
14
29
  /**
15
30
  * Build a pure descriptor for the normal tool permission gate.
16
31
  *
@@ -28,13 +43,10 @@ export function describeToolGate(
28
43
  );
29
44
 
30
45
  // Compute session approval suggestion for the "for this session" option.
31
- const suggestionValue =
32
- tcc.toolName === "bash"
33
- ? (check.command ?? "")
34
- : tcc.toolName === "mcp"
35
- ? (check.target ?? "mcp")
36
- : "*";
37
- const suggestion = suggestSessionPattern(tcc.toolName, suggestionValue);
46
+ const suggestion = suggestSessionPattern(
47
+ tcc.toolName,
48
+ deriveSuggestionValue(tcc, check),
49
+ );
38
50
 
39
51
  // Build the unavailable-reason message. Bash gets the command embedded.
40
52
  const inputCommand =
@@ -85,7 +97,11 @@ export function describeToolGate(
85
97
  },
86
98
  decision: {
87
99
  surface: tcc.toolName,
88
- value: deriveDecisionValue(tcc.toolName, check),
100
+ value: deriveDecisionValue(
101
+ tcc.toolName,
102
+ check,
103
+ getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
104
+ ),
89
105
  },
90
106
  };
91
107
  }
@@ -1,5 +1,6 @@
1
1
  import { toRecord } from "./common";
2
2
  import { createMcpPermissionTargets } from "./mcp-targets";
3
+ import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "./path-utils";
3
4
 
4
5
  /**
5
6
  * Surface-normalized representation of a tool invocation used by
@@ -85,7 +86,17 @@ export function normalizeInput(
85
86
  };
86
87
  }
87
88
 
88
- // --- Tool surfaces (read, write, edit, grep, find, ls, extension tools) ---
89
+ // --- Path-bearing tools (read, write, edit, grep, find, ls) ---
90
+ if (PATH_BEARING_TOOLS.has(toolName)) {
91
+ const path = getPathBearingToolPath(toolName, input);
92
+ return {
93
+ surface: toolName,
94
+ values: [path ?? "*"],
95
+ resultExtras: {},
96
+ };
97
+ }
98
+
99
+ // --- Extension tools (non-path-bearing) ---
89
100
  return {
90
101
  surface: toolName,
91
102
  values: ["*"],
@@ -1,4 +1,5 @@
1
1
  import { prefix } from "./bash-arity";
2
+ import { PATH_BEARING_TOOLS } from "./path-utils";
2
3
  import { deriveApprovalPattern } from "./session-rules";
3
4
 
4
5
  /** The suggestion returned for a "Yes, for this session" dialog option. */
@@ -69,7 +70,11 @@ function buildLabel(pattern: string, surface: string): string {
69
70
  case "external_directory":
70
71
  return `Yes, allow access to external directory "${pattern}" for this session`;
71
72
  default:
72
- // Tool surfaces (read, write, edit, grep, find, ls, extension tools)
73
+ // Path-bearing tools with a specific path pattern show the pattern.
74
+ if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
75
+ return `Yes, allow ${surface} "${pattern}" for this session`;
76
+ }
77
+ // Tool surfaces with catch-all or extension tools.
73
78
  return `Yes, allow tool "${surface}" for this session`;
74
79
  }
75
80
  }
@@ -100,7 +105,12 @@ export function suggestSessionPattern(
100
105
  pattern = deriveApprovalPattern(value);
101
106
  break;
102
107
  default:
103
- // Tool surfaces (read, write, edit, grep, find, ls, extension tools)
108
+ // Path-bearing tools: derive a directory-scoped pattern from the path.
109
+ if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
110
+ pattern = deriveApprovalPattern(value);
111
+ break;
112
+ }
113
+ // Extension tools / fallback.
104
114
  pattern = "*";
105
115
  break;
106
116
  }
@@ -24,6 +24,9 @@ export const SUBAGENT_ENV_HINT_KEYS = [
24
24
  export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
25
25
  // pi-agent-router (original)
26
26
  "PI_AGENT_ROUTER_PARENT_SESSION_ID",
27
+ // Shared convention for CLI-based subagent extensions
28
+ // (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
29
+ "PI_SUBAGENT_PARENT_SESSION",
27
30
  ] as const;
28
31
 
29
32
  /** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
@@ -26,9 +26,22 @@ describe("deriveDecisionValue", () => {
26
26
  expect(deriveDecisionValue("mcp", {})).toBe("mcp");
27
27
  });
28
28
 
29
- it("returns toolName for other tools", () => {
29
+ it("returns toolName for non-path-bearing tools", () => {
30
+ expect(deriveDecisionValue("my_extension_tool", {})).toBe(
31
+ "my_extension_tool",
32
+ );
33
+ });
34
+
35
+ it("returns path for path-bearing tools when path is provided", () => {
36
+ expect(deriveDecisionValue("read", {}, "/project/src/main.ts")).toBe(
37
+ "/project/src/main.ts",
38
+ );
39
+ expect(deriveDecisionValue("write", {}, "src/.env")).toBe("src/.env");
40
+ });
41
+
42
+ it("falls back to toolName for path-bearing tools when path is missing", () => {
30
43
  expect(deriveDecisionValue("read", {})).toBe("read");
31
- expect(deriveDecisionValue("write", { command: "ignored" })).toBe("write");
44
+ expect(deriveDecisionValue("write", {}, undefined)).toBe("write");
32
45
  });
33
46
  });
34
47
 
@@ -71,22 +71,59 @@ describe("normalizeInput — non-MCP surfaces", () => {
71
71
  });
72
72
  });
73
73
 
74
- describe("tool surfaces (read, write, edit, grep, find, ls, extension tools)", () => {
75
- it("uses '*' as the lookup value for built-in tools", () => {
74
+ describe("path-bearing tools (read, write, edit, grep, find, ls)", () => {
75
+ it("uses input.path as the lookup value when path is present", () => {
76
76
  for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
77
- const result = normalizeInput(tool, {}, []);
77
+ const result = normalizeInput(
78
+ tool,
79
+ { path: "/project/src/main.ts" },
80
+ [],
81
+ );
78
82
  expect(result.surface).toBe(tool);
79
- expect(result.values).toEqual(["*"]);
83
+ expect(result.values).toEqual(["/project/src/main.ts"]);
80
84
  expect(result.resultExtras).toEqual({});
81
85
  }
82
86
  });
83
87
 
88
+ it("falls back to '*' when input.path is missing", () => {
89
+ for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
90
+ const result = normalizeInput(tool, {}, []);
91
+ expect(result.values).toEqual(["*"]);
92
+ }
93
+ });
94
+
95
+ it("falls back to '*' when input.path is empty string", () => {
96
+ const result = normalizeInput("read", { path: "" }, []);
97
+ expect(result.values).toEqual(["*"]);
98
+ });
99
+
100
+ it("falls back to '*' when input.path is not a string", () => {
101
+ const result = normalizeInput("write", { path: 42 }, []);
102
+ expect(result.values).toEqual(["*"]);
103
+ });
104
+
105
+ it("falls back to '*' when input is null", () => {
106
+ const result = normalizeInput("edit", null, []);
107
+ expect(result.values).toEqual(["*"]);
108
+ });
109
+ });
110
+
111
+ describe("extension tools (non-path-bearing)", () => {
84
112
  it("uses '*' as the lookup value for extension tools", () => {
85
113
  const result = normalizeInput("my_extension_tool", { some: "input" }, []);
86
114
  expect(result.surface).toBe("my_extension_tool");
87
115
  expect(result.values).toEqual(["*"]);
88
116
  expect(result.resultExtras).toEqual({});
89
117
  });
118
+
119
+ it("uses '*' even when extension tool has a path field", () => {
120
+ const result = normalizeInput(
121
+ "my_extension_tool",
122
+ { path: "/some/path" },
123
+ [],
124
+ );
125
+ expect(result.values).toEqual(["*"]);
126
+ });
90
127
  });
91
128
  });
92
129
 
@@ -121,28 +121,51 @@ describe("suggestSessionPattern", () => {
121
121
  });
122
122
  });
123
123
 
124
- describe("tool surfaces", () => {
125
- it("returns * for read surface", () => {
126
- const result = suggestSessionPattern("read", "*");
127
- expect(result).toMatchObject({ surface: "read", pattern: "*" });
124
+ describe("path-bearing tool surfaces", () => {
125
+ it("returns directory-scoped pattern for read with a file path", () => {
126
+ const result = suggestSessionPattern("read", "/outside/project/file.ts");
127
+ expect(result).toMatchObject({
128
+ surface: "read",
129
+ pattern: "/outside/project/*",
130
+ });
128
131
  });
129
132
 
130
- it("returns * for write surface", () => {
131
- const result = suggestSessionPattern("write", "*");
132
- expect(result).toMatchObject({ surface: "write", pattern: "*" });
133
+ it("returns directory-scoped pattern for write with a file path", () => {
134
+ const result = suggestSessionPattern("write", "src/main.ts");
135
+ expect(result).toMatchObject({
136
+ surface: "write",
137
+ pattern: "src/*",
138
+ });
133
139
  });
134
140
 
135
- it("returns * for edit surface", () => {
136
- const result = suggestSessionPattern("edit", "*");
137
- expect(result).toMatchObject({ surface: "edit", pattern: "*" });
141
+ it("returns * when value is '*' (fallback)", () => {
142
+ const result = suggestSessionPattern("read", "*");
143
+ expect(result).toMatchObject({ surface: "read", pattern: "*" });
144
+ });
145
+
146
+ it("label includes the path pattern for path-bearing tools", () => {
147
+ const result = suggestSessionPattern("read", "/tmp/data/file.txt");
148
+ expect(result.label).toBe(
149
+ 'Yes, allow read "/tmp/data/*" for this session',
150
+ );
138
151
  });
139
152
 
140
- it("label shows tool name instead of bare wildcard", () => {
153
+ it("label shows tool name when pattern is *", () => {
141
154
  const result = suggestSessionPattern("find", "*");
142
155
  expect(result.label).toBe('Yes, allow tool "find" for this session');
143
156
  });
144
157
  });
145
158
 
159
+ describe("non-path-bearing tool surfaces", () => {
160
+ it("returns * for extension tools", () => {
161
+ const result = suggestSessionPattern("my_extension_tool", "*");
162
+ expect(result).toMatchObject({
163
+ surface: "my_extension_tool",
164
+ pattern: "*",
165
+ });
166
+ });
167
+ });
168
+
146
169
  describe("label field", () => {
147
170
  it("bash label includes surface prefix and pattern", () => {
148
171
  const result = suggestSessionPattern("bash", "git status");
@@ -173,7 +196,12 @@ describe("suggestSessionPattern", () => {
173
196
  );
174
197
  });
175
198
 
176
- it("tool label shows tool name, not wildcard pattern", () => {
199
+ it("path-bearing tool label includes path pattern", () => {
200
+ const result = suggestSessionPattern("edit", "src/file.ts");
201
+ expect(result.label).toBe('Yes, allow edit "src/*" for this session');
202
+ });
203
+
204
+ it("tool label shows tool name when value is *", () => {
177
205
  const result = suggestSessionPattern("edit", "*");
178
206
  expect(result.label).toBe('Yes, allow tool "edit" for this session');
179
207
  });
@@ -17,6 +17,12 @@ describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
17
17
  );
18
18
  });
19
19
 
20
+ test("contains PI_SUBAGENT_PARENT_SESSION for CLI-based subagent extensions", () => {
21
+ expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
22
+ "PI_SUBAGENT_PARENT_SESSION",
23
+ );
24
+ });
25
+
20
26
  test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
21
27
  expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
22
28
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
@@ -80,33 +86,31 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
80
86
  ).toBe("parent-session-abc");
81
87
  });
82
88
 
83
- test("isSubagent=true, first candidate absent but second set returns second value", () => {
84
- // Inject a second candidate at test-time to validate the iteration logic
85
- // without waiting for a real extension to adopt the convention.
86
- const originalCandidates = [...SUBAGENT_PARENT_SESSION_ENV_CANDIDATES];
87
- // Mutate the array via index-assignment through a cast so we can test
88
- // multi-candidate iteration without changing the exported constant type.
89
- // This is test-only; production code never mutates the array.
90
- (SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).push(
91
- "PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY",
92
- );
89
+ test("isSubagent=true, PI_SUBAGENT_PARENT_SESSION resolves when PI_AGENT_ROUTER_PARENT_SESSION_ID is absent", () => {
90
+ expect(
91
+ resolvePermissionForwardingTargetSessionId({
92
+ hasUI: false,
93
+ isSubagent: true,
94
+ currentSessionId: "session-xyz",
95
+ env: {
96
+ PI_SUBAGENT_PARENT_SESSION: "parent-from-convention",
97
+ },
98
+ }),
99
+ ).toBe("parent-from-convention");
100
+ });
93
101
 
94
- try {
95
- expect(
96
- resolvePermissionForwardingTargetSessionId({
97
- hasUI: false,
98
- isSubagent: true,
99
- currentSessionId: "session-xyz",
100
- env: {
101
- PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY: "parent-from-second",
102
- },
103
- }),
104
- ).toBe("parent-from-second");
105
- } finally {
106
- // Restore original array contents.
107
- (SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).length =
108
- originalCandidates.length;
109
- }
102
+ test("isSubagent=true, PI_AGENT_ROUTER_PARENT_SESSION_ID takes precedence over PI_SUBAGENT_PARENT_SESSION", () => {
103
+ expect(
104
+ resolvePermissionForwardingTargetSessionId({
105
+ hasUI: false,
106
+ isSubagent: true,
107
+ currentSessionId: "session-xyz",
108
+ env: {
109
+ PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-router",
110
+ PI_SUBAGENT_PARENT_SESSION: "parent-from-convention",
111
+ },
112
+ }),
113
+ ).toBe("parent-from-router");
110
114
  });
111
115
 
112
116
  test("isSubagent=true, candidate value is empty string returns null", () => {
@@ -980,3 +980,134 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
980
980
  });
981
981
  });
982
982
  });
983
+
984
+ // ---------------------------------------------------------------------------
985
+ // Per-tool path patterns (#147)
986
+ // ---------------------------------------------------------------------------
987
+
988
+ describe("checkPermission — per-tool path patterns", () => {
989
+ it("denies read of .env when path pattern matches", () => {
990
+ const { manager, cleanup } = makeManagerWithConfig({
991
+ read: { "*": "allow", "*.env": "deny" },
992
+ });
993
+ try {
994
+ const result = manager.checkPermission("read", { path: ".env" });
995
+ expect(result.state).toBe("deny");
996
+ expect(result.matchedPattern).toBe("*.env");
997
+ } finally {
998
+ cleanup();
999
+ }
1000
+ });
1001
+
1002
+ it("allows read of non-.env file when .env is denied", () => {
1003
+ const { manager, cleanup } = makeManagerWithConfig({
1004
+ read: { "*": "allow", "*.env": "deny" },
1005
+ });
1006
+ try {
1007
+ const result = manager.checkPermission("read", {
1008
+ path: "src/main.ts",
1009
+ });
1010
+ expect(result.state).toBe("allow");
1011
+ } finally {
1012
+ cleanup();
1013
+ }
1014
+ });
1015
+
1016
+ it("allows write to src/ when only src/ is allowed", () => {
1017
+ const { manager, cleanup } = makeManagerWithConfig({
1018
+ write: { "*": "deny", "src/*": "allow" },
1019
+ });
1020
+ try {
1021
+ const result = manager.checkPermission("write", {
1022
+ path: "src/main.ts",
1023
+ });
1024
+ expect(result.state).toBe("allow");
1025
+ expect(result.matchedPattern).toBe("src/*");
1026
+ } finally {
1027
+ cleanup();
1028
+ }
1029
+ });
1030
+
1031
+ it("denies write outside src/ when only src/ is allowed", () => {
1032
+ const { manager, cleanup } = makeManagerWithConfig({
1033
+ write: { "*": "deny", "src/*": "allow" },
1034
+ });
1035
+ try {
1036
+ const result = manager.checkPermission("write", {
1037
+ path: "vendor/lib.ts",
1038
+ });
1039
+ expect(result.state).toBe("deny");
1040
+ } finally {
1041
+ cleanup();
1042
+ }
1043
+ });
1044
+
1045
+ it("backward compat: 'read': 'allow' allows read of any path", () => {
1046
+ const { manager, cleanup } = makeManagerWithConfig({
1047
+ read: "allow",
1048
+ });
1049
+ try {
1050
+ const result = manager.checkPermission("read", { path: ".env" });
1051
+ expect(result.state).toBe("allow");
1052
+ } finally {
1053
+ cleanup();
1054
+ }
1055
+ });
1056
+
1057
+ it("backward compat: 'read': 'deny' denies read of any path", () => {
1058
+ const { manager, cleanup } = makeManagerWithConfig({
1059
+ read: "deny",
1060
+ });
1061
+ try {
1062
+ const result = manager.checkPermission("read", {
1063
+ path: "src/main.ts",
1064
+ });
1065
+ expect(result.state).toBe("deny");
1066
+ } finally {
1067
+ cleanup();
1068
+ }
1069
+ });
1070
+
1071
+ it("session rule for specific path overrides config deny", () => {
1072
+ const { manager, cleanup } = makeManagerWithConfig({
1073
+ read: { "*": "allow", "*.env": "deny" },
1074
+ });
1075
+ try {
1076
+ const sessionRules: Ruleset = [sessionAllow("read", ".env")];
1077
+ const result = manager.checkPermission(
1078
+ "read",
1079
+ { path: ".env" },
1080
+ undefined,
1081
+ sessionRules,
1082
+ );
1083
+ expect(result.state).toBe("allow");
1084
+ expect(result.source).toBe("session");
1085
+ } finally {
1086
+ cleanup();
1087
+ }
1088
+ });
1089
+
1090
+ it("falls back to '*' when input.path is missing", () => {
1091
+ const { manager, cleanup } = makeManagerWithConfig({
1092
+ read: { "*": "allow", "*.env": "deny" },
1093
+ });
1094
+ try {
1095
+ const result = manager.checkPermission("read", {});
1096
+ expect(result.state).toBe("allow");
1097
+ } finally {
1098
+ cleanup();
1099
+ }
1100
+ });
1101
+
1102
+ it("getToolPermission still returns surface-level state (not path-specific)", () => {
1103
+ const { manager, cleanup } = makeManagerWithConfig({
1104
+ read: { "*": "allow", "*.env": "deny" },
1105
+ });
1106
+ try {
1107
+ const toolState = manager.getToolPermission("read");
1108
+ expect(toolState).toBe("allow");
1109
+ } finally {
1110
+ cleanup();
1111
+ }
1112
+ });
1113
+ });