@gotgenes/pi-permission-system 5.15.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,41 @@ 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
+
28
+ ## [5.16.0](https://github.com/gotgenes/pi-permission-system/compare/v5.15.0...v5.16.0) (2026-05-13)
29
+
30
+
31
+ ### Features
32
+
33
+ * 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))
34
+ * 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))
35
+ * 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))
36
+
37
+
38
+ ### Documentation
39
+
40
+ * 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))
41
+ * 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))
42
+
8
43
  ## [5.15.0](https://github.com/gotgenes/pi-permission-system/compare/v5.14.1...v5.15.0) (2026-05-13)
9
44
 
10
45
 
package/README.md CHANGED
@@ -34,6 +34,12 @@ pi install npm:@gotgenes/pi-permission-system
34
34
  {
35
35
  "permission": {
36
36
  "*": "allow",
37
+ "path": {
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,14 @@ 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
+ 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.
72
+
59
73
  ## Configuration
60
74
 
61
75
  Config lives in one JSON file per scope:
@@ -9,8 +9,15 @@
9
9
 
10
10
  "permission": {
11
11
  "*": "ask",
12
+ "path": {
13
+ "*": "allow",
14
+ "*.env": "deny",
15
+ "*.env.*": "deny",
16
+ "*.env.example": "allow"
17
+ },
12
18
  "read": "allow",
13
19
  "write": "deny",
20
+ "edit": "deny",
14
21
  "bash": {
15
22
  "*": "ask",
16
23
  "git status": "allow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.15.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\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,8 +63,15 @@
63
63
  "examples": [
64
64
  {
65
65
  "*": "ask",
66
+ "path": {
67
+ "*": "allow",
68
+ "*.env": "deny",
69
+ "*.env.*": "deny",
70
+ "*.env.example": "allow"
71
+ },
66
72
  "read": "allow",
67
73
  "write": "deny",
74
+ "edit": "deny",
68
75
  "bash": {
69
76
  "*": "ask",
70
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
+ }
@@ -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,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
+ }
@@ -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
  }
@@ -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,
@@ -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
@@ -21,7 +22,7 @@ export interface NormalizedInput {
21
22
  resultExtras: Record<string, unknown>;
22
23
  }
23
24
 
24
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
25
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
25
26
 
26
27
  /**
27
28
  * Map a raw tool invocation to the surface/values/extras triple needed 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: ["*"],