@gotgenes/pi-permission-system 5.16.0 → 5.17.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,26 @@ 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.17.0](https://github.com/gotgenes/pi-permission-system/compare/v5.16.0...v5.17.0) (2026-05-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * bash path gate with broader token extraction ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([affe202](https://github.com/gotgenes/pi-permission-system/commit/affe20284c7b579facc46ba489a1b6b0e2acc949))
14
+ * broader token extraction for path rules ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([6303641](https://github.com/gotgenes/pi-permission-system/commit/6303641a6efc3265e209799b93d4c8bcbc17c6a0))
15
+ * evaluateMostRestrictive helper for cross-cutting path evaluation ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([5260f21](https://github.com/gotgenes/pi-permission-system/commit/5260f21f149f8cd9b3331c4e418bc9091db2acdb))
16
+ * integrate path gates into permission pipeline ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([36fb30e](https://github.com/gotgenes/pi-permission-system/commit/36fb30e2564a8707b8e6eb8b798b90d536623c53))
17
+ * path gate for tool path restrictions ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([cc53681](https://github.com/gotgenes/pi-permission-system/commit/cc5368103686eee0849644cffce463c2851dff3c))
18
+ * register path as a special permission surface ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([356bcf7](https://github.com/gotgenes/pi-permission-system/commit/356bcf74b3028894319ea3c63b5d4b014b7bfe48))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * document cross-cutting path permission surface ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([3bd4478](https://github.com/gotgenes/pi-permission-system/commit/3bd4478c95197550485910c4364d35203ff53ada))
24
+ * include edit alongside write in config examples ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([f083ccc](https://github.com/gotgenes/pi-permission-system/commit/f083ccc0316e0689390e5ecc16e14ab40baca1d9))
25
+ * plan path-aware bash permission rules ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([71ff973](https://github.com/gotgenes/pi-permission-system/commit/71ff973edd57d75801b75044e238139e90a8490f))
26
+ * **retro:** add retro notes for issue [#147](https://github.com/gotgenes/pi-permission-system/issues/147) ([e40402b](https://github.com/gotgenes/pi-permission-system/commit/e40402b018703c37c8af436dc4e15665216f59c7))
27
+
8
28
  ## [5.16.0](https://github.com/gotgenes/pi-permission-system/compare/v5.15.0...v5.16.0) (2026-05-13)
9
29
 
10
30
 
package/README.md CHANGED
@@ -34,7 +34,7 @@ pi install npm:@gotgenes/pi-permission-system
34
34
  {
35
35
  "permission": {
36
36
  "*": "allow",
37
- "read": {
37
+ "path": {
38
38
  "*": "allow",
39
39
  "*.env": "deny",
40
40
  "*.env.*": "deny",
@@ -62,8 +62,13 @@ All permissions use one of three states:
62
62
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
63
63
  See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
64
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.
65
+ The `path` surface is a cross-cutting gate that applies to **all** file access both Pi tools and bash commands.
66
+ 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.
67
+
68
+ For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
69
+ This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
70
+
71
+ Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
67
72
 
68
73
  ## Configuration
69
74
 
@@ -9,13 +9,15 @@
9
9
 
10
10
  "permission": {
11
11
  "*": "ask",
12
- "read": {
12
+ "path": {
13
13
  "*": "allow",
14
14
  "*.env": "deny",
15
15
  "*.env.*": "deny",
16
16
  "*.env.example": "allow"
17
17
  },
18
+ "read": "allow",
18
19
  "write": "deny",
20
+ "edit": "deny",
19
21
  "bash": {
20
22
  "*": "ask",
21
23
  "git status": "allow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.16.0",
3
+ "version": "5.17.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\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.",
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`, `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.",
45
45
  "type": "object",
46
46
  "propertyNames": {
47
47
  "description": "A surface name or the universal fallback key '*'.",
@@ -63,13 +63,15 @@
63
63
  "examples": [
64
64
  {
65
65
  "*": "ask",
66
- "read": {
66
+ "path": {
67
67
  "*": "allow",
68
68
  "*.env": "deny",
69
69
  "*.env.*": "deny",
70
70
  "*.env.example": "allow"
71
71
  },
72
+ "read": "allow",
72
73
  "write": "deny",
74
+ "edit": "deny",
73
75
  "bash": {
74
76
  "*": "ask",
75
77
  "git status": "allow",
@@ -433,6 +433,43 @@ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
433
433
  */
434
434
  const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
435
435
 
436
+ /**
437
+ * Broader token classification for cross-cutting `path` rules.
438
+ *
439
+ * Accepts the same rejections as `classifyTokenAsPathCandidate` (empty, flags,
440
+ * env assignments, URLs, @scope/package, bare-slash, regex metacharacters),
441
+ * but also accepts:
442
+ * - Tokens starting with `.` (dot-files: `.env`, `./src`)
443
+ * - Tokens containing `/` (relative paths: `src/foo.ts`)
444
+ *
445
+ * Does NOT require the strict "must start with `/` or `~/` or contain `..`"
446
+ * gate that the external-directory classifier uses.
447
+ */
448
+ function classifyTokenAsRuleCandidate(token: string): string | null {
449
+ if (!token) return null;
450
+ if (token.startsWith("-")) return null;
451
+
452
+ const eqIndex = token.indexOf("=");
453
+ const slashIndex = token.indexOf("/");
454
+ if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
455
+ return null;
456
+ }
457
+
458
+ if (URL_PATTERN.test(token)) return null;
459
+ if (token.startsWith("@") && !token.startsWith("@/")) return null;
460
+ if (/^\/+$/.test(token)) return null;
461
+ if (REGEX_METACHAR_PATTERN.test(token)) return null;
462
+
463
+ // Accept: starts with . (dot-files, ./ relative), contains / (paths),
464
+ // starts with / or ~/ (absolute/home), or contains .. (parent traversal).
465
+ if (token.startsWith(".")) return token;
466
+ if (token.includes("/")) return token;
467
+ if (token.startsWith("~/")) return token;
468
+ if (token.includes("..")) return token;
469
+
470
+ return null;
471
+ }
472
+
436
473
  /**
437
474
  * Determines whether a token looks like a path candidate worth resolving.
438
475
  * Returns the raw token string if it's a candidate, or null to skip.
@@ -516,3 +553,41 @@ export async function extractExternalPathsFromBashCommand(
516
553
 
517
554
  return externalPaths;
518
555
  }
556
+
557
+ /**
558
+ * Extract tokens from a bash command that may be file paths, using a broader
559
+ * filter suitable for cross-cutting `path` permission rules.
560
+ *
561
+ * Unlike `extractExternalPathsFromBashCommand`, this function:
562
+ * - Accepts relative paths (`.env`, `src/foo.ts`, `./build`)
563
+ * - Does NOT filter by CWD — returns raw tokens for rule evaluation
564
+ * - Returns deduplicated tokens
565
+ */
566
+ export async function extractTokensForPathRules(
567
+ command: string,
568
+ ): Promise<string[]> {
569
+ const parser = await getParser();
570
+ const tree = parser.parse(command);
571
+ if (!tree) return [];
572
+
573
+ const tokens: string[] = [];
574
+ try {
575
+ collectPathCandidateTokens(tree.rootNode, tokens);
576
+ } finally {
577
+ tree.delete();
578
+ }
579
+
580
+ const seen = new Set<string>();
581
+ const result: string[] = [];
582
+
583
+ for (const token of tokens) {
584
+ const candidate = classifyTokenAsRuleCandidate(token);
585
+ if (!candidate) continue;
586
+ if (!seen.has(candidate)) {
587
+ seen.add(candidate);
588
+ result.push(candidate);
589
+ }
590
+ }
591
+
592
+ return result;
593
+ }
@@ -0,0 +1,146 @@
1
+ import { getNonEmptyString, toRecord } from "../../common";
2
+ import type { Rule } from "../../rule";
3
+ import { deriveApprovalPattern } from "../../session-rules";
4
+ import type { PermissionCheckResult } from "../../types";
5
+ import { extractTokensForPathRules } from "./bash-path-extractor";
6
+ import type { GateResult } from "./descriptor";
7
+ import { formatPathAskPrompt, formatPathDenyReason } from "./path";
8
+ import type { ToolCallContext } from "./types";
9
+
10
+ /** Function type for checkPermission used by the descriptor factory. */
11
+ type CheckPermissionFn = (
12
+ surface: string,
13
+ input: unknown,
14
+ agentName?: string,
15
+ sessionRules?: Rule[],
16
+ ) => PermissionCheckResult;
17
+
18
+ /**
19
+ * Build a pure descriptor for the cross-cutting path permission gate (bash).
20
+ *
21
+ * Extracts path-candidate tokens from a bash command using tree-sitter with
22
+ * the broader filter (accepts dot-files, relative paths). Evaluates each
23
+ * token against the `path` permission surface and returns the most
24
+ * restrictive result.
25
+ *
26
+ * Returns `null` when the gate does not apply (tool is not bash, no command,
27
+ * no tokens extracted, or all tokens evaluate to `allow`).
28
+ * Returns a `GateBypass` when all tokens are session-covered.
29
+ * Returns a `GateDescriptor` for the most restrictive token needing a check.
30
+ */
31
+ export async function describeBashPathGate(
32
+ tcc: ToolCallContext,
33
+ checkPermission: CheckPermissionFn,
34
+ getSessionRuleset: () => Rule[],
35
+ ): Promise<GateResult> {
36
+ if (tcc.toolName !== "bash") return null;
37
+
38
+ const command = getNonEmptyString(toRecord(tcc.input).command);
39
+ if (!command) return null;
40
+
41
+ const tokens = await extractTokensForPathRules(command);
42
+ if (tokens.length === 0) return null;
43
+
44
+ // Check each token against path rules with session rules appended.
45
+ const sessionRules = getSessionRuleset();
46
+
47
+ let worstCheck: PermissionCheckResult | null = null;
48
+ let worstToken: string | null = null;
49
+ let allSessionCovered = true;
50
+
51
+ for (const token of tokens) {
52
+ const check = checkPermission(
53
+ "path",
54
+ { path: token },
55
+ tcc.agentName ?? undefined,
56
+ sessionRules,
57
+ );
58
+
59
+ if (check.source !== "session") {
60
+ allSessionCovered = false;
61
+ }
62
+
63
+ if (check.state === "deny") {
64
+ worstCheck = check;
65
+ worstToken = token;
66
+ break; // Short-circuit on deny.
67
+ }
68
+ if (check.state === "ask" && (!worstCheck || worstCheck.state !== "ask")) {
69
+ worstCheck = check;
70
+ worstToken = token;
71
+ }
72
+ }
73
+
74
+ // All tokens are session-covered — bypass.
75
+ if (allSessionCovered) {
76
+ return {
77
+ action: "allow",
78
+ log: {
79
+ event: "permission_request.session_approved",
80
+ details: {
81
+ source: "tool_call",
82
+ toolCallId: tcc.toolCallId,
83
+ toolName: tcc.toolName,
84
+ agentName: tcc.agentName,
85
+ command,
86
+ tokens,
87
+ resolution: "session_approved",
88
+ },
89
+ },
90
+ };
91
+ }
92
+
93
+ // All tokens evaluate to allow — no restriction.
94
+ if (!worstCheck || !worstToken) return null;
95
+
96
+ const pattern = deriveApprovalPattern(worstToken);
97
+ const askMessage = formatPathAskPrompt(
98
+ tcc.toolName,
99
+ worstToken,
100
+ tcc.agentName ?? undefined,
101
+ );
102
+
103
+ return {
104
+ surface: "path",
105
+ input: { path: worstToken },
106
+ messages: {
107
+ denyReason: formatPathDenyReason(
108
+ tcc.toolName,
109
+ worstToken,
110
+ tcc.agentName ?? undefined,
111
+ ),
112
+ unavailableReason: `Bash command '${command}' accesses path '${worstToken}' which requires approval, but no interactive UI is available.`,
113
+ userDeniedReason: (decision) => {
114
+ const reasonSuffix = decision.denialReason
115
+ ? ` Reason: ${decision.denialReason}.`
116
+ : "";
117
+ return `User denied path access for bash command '${command}' (path '${worstToken}').${reasonSuffix} Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
118
+ },
119
+ },
120
+ sessionApproval: {
121
+ surface: "path",
122
+ pattern,
123
+ },
124
+ promptDetails: {
125
+ source: "tool_call",
126
+ agentName: tcc.agentName,
127
+ message: askMessage,
128
+ toolCallId: tcc.toolCallId,
129
+ toolName: tcc.toolName,
130
+ command,
131
+ },
132
+ logContext: {
133
+ source: "tool_call",
134
+ toolCallId: tcc.toolCallId,
135
+ toolName: tcc.toolName,
136
+ agentName: tcc.agentName,
137
+ command,
138
+ path: worstToken,
139
+ },
140
+ decision: {
141
+ surface: "path",
142
+ value: worstToken,
143
+ },
144
+ preCheck: worstCheck,
145
+ };
146
+ }
@@ -1,4 +1,5 @@
1
1
  export { describeBashExternalDirectoryGate } from "./bash-external-directory";
2
+ export { describeBashPathGate } from "./bash-path";
2
3
  export type {
3
4
  GateBypass,
4
5
  GateDescriptor,
@@ -8,6 +9,7 @@ export type {
8
9
  export { isGateBypass, isGateDescriptor } from "./descriptor";
9
10
  export { describeExternalDirectoryGate } from "./external-directory";
10
11
  export { deriveDecisionValue, deriveResolution } from "./helpers";
12
+ export { describePathGate } from "./path";
11
13
  export { runGateCheck } from "./runner";
12
14
  export { describeSkillReadGate } from "./skill-read";
13
15
  export { describeToolGate } from "./tool";
@@ -0,0 +1,104 @@
1
+ import { getPathBearingToolPath } from "../../path-utils";
2
+ import { deriveApprovalPattern } from "../../session-rules";
3
+ import type { PermissionCheckResult } from "../../types";
4
+ import type { GateDescriptor, GateResult } from "./descriptor";
5
+ import type { ToolCallContext } from "./types";
6
+
7
+ /** Function type for checkPermission used by the descriptor factory. */
8
+ type CheckPermissionFn = (
9
+ surface: string,
10
+ input: unknown,
11
+ agentName?: string,
12
+ ) => PermissionCheckResult;
13
+
14
+ /**
15
+ * Build a pure descriptor for the cross-cutting path permission gate (tools).
16
+ *
17
+ * Returns `null` when the gate does not apply (tool is not path-bearing,
18
+ * no extractable path, or the `path` surface evaluates to `allow`).
19
+ * Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
20
+ */
21
+ export function describePathGate(
22
+ tcc: ToolCallContext,
23
+ checkPermission: CheckPermissionFn,
24
+ ): GateResult {
25
+ const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
26
+ if (!filePath) return null;
27
+
28
+ const check = checkPermission(
29
+ "path",
30
+ { path: filePath },
31
+ tcc.agentName ?? undefined,
32
+ );
33
+
34
+ if (check.state === "allow") return null;
35
+
36
+ const pattern = deriveApprovalPattern(filePath);
37
+
38
+ const descriptor: GateDescriptor = {
39
+ surface: "path",
40
+ input: { path: filePath },
41
+ messages: {
42
+ denyReason: formatPathDenyReason(
43
+ tcc.toolName,
44
+ filePath,
45
+ tcc.agentName ?? undefined,
46
+ ),
47
+ unavailableReason: `Accessing '${filePath}' requires approval, but no interactive UI is available.`,
48
+ userDeniedReason: (decision) => {
49
+ const reasonSuffix = decision.denialReason
50
+ ? ` Reason: ${decision.denialReason}.`
51
+ : "";
52
+ return `User denied access to path '${filePath}'.${reasonSuffix} Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
53
+ },
54
+ },
55
+ sessionApproval: {
56
+ surface: "path",
57
+ pattern,
58
+ },
59
+ promptDetails: {
60
+ source: "tool_call",
61
+ agentName: tcc.agentName,
62
+ message: formatPathAskPrompt(
63
+ tcc.toolName,
64
+ filePath,
65
+ tcc.agentName ?? undefined,
66
+ ),
67
+ toolCallId: tcc.toolCallId,
68
+ toolName: tcc.toolName,
69
+ path: filePath,
70
+ },
71
+ logContext: {
72
+ source: "tool_call",
73
+ toolCallId: tcc.toolCallId,
74
+ toolName: tcc.toolName,
75
+ agentName: tcc.agentName,
76
+ path: filePath,
77
+ },
78
+ decision: {
79
+ surface: "path",
80
+ value: filePath,
81
+ },
82
+ preCheck: check,
83
+ };
84
+
85
+ return descriptor;
86
+ }
87
+
88
+ export function formatPathDenyReason(
89
+ toolName: string,
90
+ pathValue: string,
91
+ agentName?: string,
92
+ ): string {
93
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
94
+ return `${subject} is not permitted to access path '${pathValue}' via tool '${toolName}'. Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
95
+ }
96
+
97
+ export function formatPathAskPrompt(
98
+ toolName: string,
99
+ pathValue: string,
100
+ agentName?: string,
101
+ ): string {
102
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
103
+ return `${subject} requested tool '${toolName}' for path '${pathValue}'. Allow this path access?`;
104
+ }
@@ -22,9 +22,11 @@ import {
22
22
  type ToolRegistry,
23
23
  } from "../tool-registry";
24
24
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
25
+ import { describeBashPathGate } from "./gates/bash-path";
25
26
  import type { GateRunnerDeps } from "./gates/descriptor";
26
27
  import { isGateBypass } from "./gates/descriptor";
27
28
  import { describeExternalDirectoryGate } from "./gates/external-directory";
29
+ import { describePathGate } from "./gates/path";
28
30
  import { runGateCheck } from "./gates/runner";
29
31
  import { describeSkillReadGate } from "./gates/skill-read";
30
32
  import { describeToolGate } from "./gates/tool";
@@ -140,6 +142,26 @@ export class PermissionGateHandler {
140
142
  }
141
143
  }
142
144
 
145
+ // ── Path gate for tools (descriptor + runner) ────────────────────────────
146
+ const pathDesc = describePathGate(tcc, checkPermission);
147
+ if (pathDesc) {
148
+ if (isGateBypass(pathDesc)) {
149
+ if (pathDesc.log) {
150
+ writeReviewLog(pathDesc.log.event, pathDesc.log.details);
151
+ }
152
+ } else {
153
+ const pathResult = await runGateCheck(
154
+ pathDesc,
155
+ tcc.agentName,
156
+ tcc.toolCallId,
157
+ runnerDeps,
158
+ );
159
+ if (pathResult.action === "block") {
160
+ return { block: true, reason: pathResult.reason };
161
+ }
162
+ }
163
+ }
164
+
143
165
  // ── External-directory gate (descriptor + runner) ────────────────────────
144
166
  const infraDirs = [
145
167
  ...session.getInfrastructureDirs(),
@@ -191,6 +213,30 @@ export class PermissionGateHandler {
191
213
  }
192
214
  }
193
215
 
216
+ // ── Bash path gate (descriptor + runner) ────────────────────────────────
217
+ const bashPathDesc = await describeBashPathGate(
218
+ tcc,
219
+ checkPermission,
220
+ getSessionRuleset,
221
+ );
222
+ if (bashPathDesc) {
223
+ if (isGateBypass(bashPathDesc)) {
224
+ if (bashPathDesc.log) {
225
+ writeReviewLog(bashPathDesc.log.event, bashPathDesc.log.details);
226
+ }
227
+ } else {
228
+ const bashPathResult = await runGateCheck(
229
+ bashPathDesc,
230
+ tcc.agentName,
231
+ tcc.toolCallId,
232
+ runnerDeps,
233
+ );
234
+ if (bashPathResult.action === "block") {
235
+ return { block: true, reason: bashPathResult.reason };
236
+ }
237
+ }
238
+ }
239
+
194
240
  // ── Normal tool permission gate (descriptor + runner) ────────────────────
195
241
  const toolCheck = checkPermission(
196
242
  tcc.toolName,
@@ -22,7 +22,7 @@ export interface NormalizedInput {
22
22
  resultExtras: Record<string, unknown>;
23
23
  }
24
24
 
25
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
25
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
26
26
 
27
27
  /**
28
28
  * Map a raw tool invocation to the surface/values/extras triple needed by
@@ -30,7 +30,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
30
30
  "find",
31
31
  "ls",
32
32
  ]);
33
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
33
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
34
34
 
35
35
  /** Universal fallback when permission["*"] is absent from all scopes. */
36
36
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
package/src/rule.ts CHANGED
@@ -79,6 +79,33 @@ export function evaluate(
79
79
  * evaluating the first candidate so the caller always receives a concrete
80
80
  * result.
81
81
  */
82
+ /**
83
+ * Evaluate a surface against multiple values, returning the most restrictive
84
+ * non-allow result (deny > ask > allow).
85
+ *
86
+ * Used by the cross-cutting `path` surface to aggregate permission decisions
87
+ * across multiple file paths extracted from a single tool call or bash command.
88
+ *
89
+ * Returns `null` when all values evaluate to `allow` (no restriction).
90
+ * Returns the first `deny` immediately (short-circuit).
91
+ * Returns the first `ask` if no `deny` is found.
92
+ */
93
+ export function evaluateMostRestrictive(
94
+ surface: string,
95
+ values: string[],
96
+ rules: Ruleset,
97
+ ): { rule: Rule; value: string } | null {
98
+ let worst: { rule: Rule; value: string } | null = null;
99
+ for (const value of values) {
100
+ const rule = evaluate(surface, value, rules);
101
+ if (rule.action === "deny") return { rule, value };
102
+ if (rule.action === "ask" && worst?.rule.action !== "ask") {
103
+ worst = { rule, value };
104
+ }
105
+ }
106
+ return worst;
107
+ }
108
+
82
109
  export function evaluateFirst(
83
110
  surface: string,
84
111
  values: string[],
@@ -9,7 +9,10 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
- import { extractExternalPathsFromBashCommand } from "../src/handlers/gates/bash-path-extractor";
12
+ import {
13
+ extractExternalPathsFromBashCommand,
14
+ extractTokensForPathRules,
15
+ } from "../src/handlers/gates/bash-path-extractor";
13
16
  import {
14
17
  formatBashExternalDirectoryAskPrompt,
15
18
  formatBashExternalDirectoryDenyReason,
@@ -887,3 +890,80 @@ describe("formatBashExternalDirectoryDenyReason", () => {
887
890
  expect(result).toContain("my-agent");
888
891
  });
889
892
  });
893
+
894
+ describe("extractTokensForPathRules", () => {
895
+ test("extracts dot-files: cat .env", async () => {
896
+ const tokens = await extractTokensForPathRules("cat .env");
897
+ expect(tokens).toContain(".env");
898
+ });
899
+
900
+ test("extracts relative dot-paths: git add src/.env", async () => {
901
+ const tokens = await extractTokensForPathRules("git add src/.env");
902
+ expect(tokens).toContain("src/.env");
903
+ });
904
+
905
+ test("extracts nothing from plain words: echo hello", async () => {
906
+ const tokens = await extractTokensForPathRules("echo hello");
907
+ expect(tokens).toHaveLength(0);
908
+ });
909
+
910
+ test("extracts ./src and skips flags: rm -rf ./src", async () => {
911
+ const tokens = await extractTokensForPathRules("rm -rf ./src");
912
+ expect(tokens).toContain("./src");
913
+ expect(tokens).not.toContain("-rf");
914
+ });
915
+
916
+ test("extracts absolute paths: cat /etc/hosts", async () => {
917
+ const tokens = await extractTokensForPathRules("cat /etc/hosts");
918
+ expect(tokens).toContain("/etc/hosts");
919
+ });
920
+
921
+ test("skips URLs: curl https://example.com", async () => {
922
+ const tokens = await extractTokensForPathRules("curl https://example.com");
923
+ expect(tokens).not.toContain("https://example.com");
924
+ });
925
+
926
+ test("extracts slash-containing tokens: cat src/foo.ts", async () => {
927
+ const tokens = await extractTokensForPathRules("cat src/foo.ts");
928
+ expect(tokens).toContain("src/foo.ts");
929
+ });
930
+
931
+ test("skips heredoc content", async () => {
932
+ const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
933
+ expect(tokens).not.toContain(".env");
934
+ });
935
+
936
+ test("skips @scope/package patterns", async () => {
937
+ const tokens = await extractTokensForPathRules(
938
+ "npm install @scope/package",
939
+ );
940
+ expect(tokens).not.toContain("@scope/package");
941
+ });
942
+
943
+ test("skips env assignments", async () => {
944
+ const tokens = await extractTokensForPathRules("FOO=/bar command");
945
+ expect(tokens).not.toContain("FOO=/bar");
946
+ });
947
+
948
+ test("skips bare-slash tokens", async () => {
949
+ const tokens = await extractTokensForPathRules("ls /");
950
+ expect(tokens).not.toContain("/");
951
+ });
952
+
953
+ test("extracts redirect targets: echo test > .env", async () => {
954
+ const tokens = await extractTokensForPathRules("echo test > .env");
955
+ expect(tokens).toContain(".env");
956
+ });
957
+
958
+ test("extracts multiple path tokens: cp .env .env.backup", async () => {
959
+ const tokens = await extractTokensForPathRules("cp .env .env.backup");
960
+ expect(tokens).toContain(".env");
961
+ expect(tokens).toContain(".env.backup");
962
+ });
963
+
964
+ test("deduplicates repeated tokens", async () => {
965
+ const tokens = await extractTokensForPathRules("cat .env && rm .env");
966
+ const envCount = tokens.filter((t) => t === ".env").length;
967
+ expect(envCount).toBe(1);
968
+ });
969
+ });