@gotgenes/pi-permission-system 5.3.3 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.4.0](https://github.com/gotgenes/pi-permission-system/compare/v5.3.4...v5.4.0) (2026-05-07)
9
+
10
+
11
+ ### Features
12
+
13
+ * add npm shim to enforce pnpm usage via mise ([6a446b2](https://github.com/gotgenes/pi-permission-system/commit/6a446b2e94c125f36377a2388dba2adbd0305459))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * add redundant integration test cleanup step ([#107](https://github.com/gotgenes/pi-permission-system/issues/107)) ([236d812](https://github.com/gotgenes/pi-permission-system/commit/236d812f35821860fd5253fedb0e8b26386a34ac))
19
+ * expand gate test surfaces in plan ([#107](https://github.com/gotgenes/pi-permission-system/issues/107)) ([d671556](https://github.com/gotgenes/pi-permission-system/commit/d671556054d35c4d542f9501600c546cb3574207))
20
+ * plan extract per-gate functions from handleToolCall ([#107](https://github.com/gotgenes/pi-permission-system/issues/107)) ([c867d34](https://github.com/gotgenes/pi-permission-system/commit/c867d345e70aad0be953275e5712270e154f4f0a))
21
+ * update architecture for gate extraction ([#107](https://github.com/gotgenes/pi-permission-system/issues/107)) ([fe4c967](https://github.com/gotgenes/pi-permission-system/commit/fe4c967d683be96031a7070390a8b8687dbb28ba))
22
+
23
+ ## [5.3.4](https://github.com/gotgenes/pi-permission-system/compare/v5.3.3...v5.3.4) (2026-05-06)
24
+
25
+
26
+ ### Documentation
27
+
28
+ * center logo with HTML align ([707f3e7](https://github.com/gotgenes/pi-permission-system/commit/707f3e7b6dead1d7f82942e4925ca137248e77ab))
29
+ * convert logo to PNG for npm compatibility ([c81e094](https://github.com/gotgenes/pi-permission-system/commit/c81e0949bcb6bd7acd11950050fcd4eb678d6e51))
30
+ * remove width constraint on logo ([d50930a](https://github.com/gotgenes/pi-permission-system/commit/d50930ac4e6dd7fe6e94f42338775b8e3a0093eb))
31
+
8
32
  ## [5.3.3](https://github.com/gotgenes/pi-permission-system/compare/v5.3.2...v5.3.3) (2026-05-06)
9
33
 
10
34
 
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/logo.svg" alt="pi-permission-system logo" width="200" height="200">
2
+ <img src="docs/assets/logo.png" alt="pi-permission-system logo">
3
3
  </p>
4
4
 
5
5
  # @gotgenes/pi-permission-system
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.3.3",
3
+ "version": "5.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,134 @@
1
+ import { getNonEmptyString, toRecord } from "../../common";
2
+ import {
3
+ extractExternalPathsFromBashCommand,
4
+ formatBashExternalDirectoryAskPrompt,
5
+ formatBashExternalDirectoryDenyReason,
6
+ formatExternalDirectoryHardStopHint,
7
+ } from "../../external-directory";
8
+ import type { PermissionPromptDecision } from "../../permission-dialog";
9
+ import { applyPermissionGate } from "../../permission-gate";
10
+ import { deriveApprovalPattern } from "../../session-rules";
11
+ import type { HandlerDeps } from "../types";
12
+ import type { GateOutcome, ToolCallContext } from "./types";
13
+
14
+ /**
15
+ * Evaluate the bash external-directory permission gate.
16
+ *
17
+ * Extracts paths from a bash command and checks whether any reference
18
+ * directories outside the working directory. Returns `null` when the gate
19
+ * does not apply (tool is not bash, no CWD, or no external paths found).
20
+ */
21
+ export async function evaluateBashExternalDirectoryGate(
22
+ tcc: ToolCallContext,
23
+ deps: HandlerDeps,
24
+ ): Promise<GateOutcome | null> {
25
+ if (tcc.toolName !== "bash" || !tcc.cwd) return null;
26
+
27
+ const command = getNonEmptyString(toRecord(tcc.input).command);
28
+ if (!command) return null;
29
+
30
+ const externalPaths = await extractExternalPathsFromBashCommand(
31
+ command,
32
+ tcc.cwd,
33
+ );
34
+ if (externalPaths.length === 0) return null;
35
+
36
+ const bashSessionRules = deps.runtime.sessionRules.getRuleset();
37
+ const uncoveredPaths = externalPaths.filter(
38
+ (p) =>
39
+ deps.runtime.permissionManager.checkPermission(
40
+ "external_directory",
41
+ { path: p },
42
+ tcc.agentName ?? undefined,
43
+ bashSessionRules,
44
+ ).source !== "session",
45
+ );
46
+
47
+ if (uncoveredPaths.length === 0) {
48
+ deps.runtime.writeReviewLog("permission_request.session_approved", {
49
+ source: "tool_call",
50
+ toolCallId: tcc.toolCallId,
51
+ toolName: tcc.toolName,
52
+ agentName: tcc.agentName,
53
+ command,
54
+ externalPaths,
55
+ resolution: "session_approved",
56
+ });
57
+ return null;
58
+ }
59
+
60
+ // Get the config-level policy (no path → no session check).
61
+ const extCheck = deps.runtime.permissionManager.checkPermission(
62
+ "external_directory",
63
+ {},
64
+ tcc.agentName ?? undefined,
65
+ );
66
+
67
+ let bashExtDecision: PermissionPromptDecision | null = null;
68
+ const bashExtMessage = formatBashExternalDirectoryAskPrompt(
69
+ command,
70
+ uncoveredPaths,
71
+ tcc.cwd,
72
+ tcc.agentName ?? undefined,
73
+ );
74
+ const bashExtGate = await applyPermissionGate({
75
+ state: extCheck.state,
76
+ canConfirm: deps.canRequestPermissionConfirmation(
77
+ deps.runtime.runtimeContext!,
78
+ ),
79
+ promptForApproval: async () => {
80
+ const decision = await deps.promptPermission(
81
+ deps.runtime.runtimeContext!,
82
+ {
83
+ requestId: tcc.toolCallId,
84
+ source: "tool_call",
85
+ agentName: tcc.agentName,
86
+ message: bashExtMessage,
87
+ toolCallId: tcc.toolCallId,
88
+ toolName: tcc.toolName,
89
+ command,
90
+ },
91
+ );
92
+ bashExtDecision = decision;
93
+ return decision;
94
+ },
95
+ writeLog: deps.runtime.writeReviewLog,
96
+ logContext: {
97
+ source: "tool_call",
98
+ toolCallId: tcc.toolCallId,
99
+ toolName: tcc.toolName,
100
+ agentName: tcc.agentName,
101
+ command,
102
+ externalPaths: uncoveredPaths,
103
+ message: bashExtMessage,
104
+ },
105
+ messages: {
106
+ denyReason: formatBashExternalDirectoryDenyReason(
107
+ command,
108
+ uncoveredPaths,
109
+ tcc.cwd,
110
+ tcc.agentName ?? undefined,
111
+ ),
112
+ unavailableReason: `Bash command '${command}' references path(s) outside the working directory and 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 external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
118
+ },
119
+ },
120
+ });
121
+
122
+ if (bashExtGate.action === "block") {
123
+ return { action: "block", reason: bashExtGate.reason };
124
+ }
125
+
126
+ if (bashExtDecision?.state === "approved_for_session") {
127
+ for (const extPath of uncoveredPaths) {
128
+ const pattern = deriveApprovalPattern(extPath);
129
+ deps.runtime.sessionRules.approve("external_directory", pattern);
130
+ }
131
+ }
132
+
133
+ return { action: "allow" };
134
+ }
@@ -0,0 +1,189 @@
1
+ import {
2
+ formatExternalDirectoryAskPrompt,
3
+ formatExternalDirectoryDenyReason,
4
+ formatExternalDirectoryUserDeniedReason,
5
+ getPathBearingToolPath,
6
+ isPathOutsideWorkingDirectory,
7
+ isPiInfrastructureRead,
8
+ normalizePathForComparison,
9
+ } from "../../external-directory";
10
+ import type { PermissionPromptDecision } from "../../permission-dialog";
11
+ import { emitDecisionEvent } from "../../permission-events";
12
+ import { applyPermissionGate } from "../../permission-gate";
13
+ import { deriveApprovalPattern } from "../../session-rules";
14
+ import type { HandlerDeps } from "../types";
15
+ import { deriveResolution } from "./helpers";
16
+ import type { GateOutcome, ToolCallContext } from "./types";
17
+
18
+ /**
19
+ * Evaluate the external-directory permission gate for file tools.
20
+ *
21
+ * Returns `null` when the gate does not apply (no CWD, tool is not
22
+ * path-bearing, or path is inside the working directory).
23
+ */
24
+ export async function evaluateExternalDirectoryGate(
25
+ tcc: ToolCallContext,
26
+ deps: HandlerDeps,
27
+ ): Promise<GateOutcome | null> {
28
+ if (!tcc.cwd) return null;
29
+
30
+ const externalDirectoryPath = getPathBearingToolPath(tcc.toolName, tcc.input);
31
+ if (!externalDirectoryPath) return null;
32
+
33
+ if (!isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd)) {
34
+ return null;
35
+ }
36
+
37
+ const normalizedExtPath = normalizePathForComparison(
38
+ externalDirectoryPath,
39
+ tcc.cwd,
40
+ );
41
+
42
+ // ── Pi infrastructure read bypass ──────────────────────────────────────
43
+ const allInfraDirs = [
44
+ ...deps.runtime.piInfrastructureDirs,
45
+ ...(deps.runtime.config.piInfrastructureReadPaths ?? []),
46
+ ];
47
+ if (
48
+ isPiInfrastructureRead(
49
+ tcc.toolName,
50
+ normalizedExtPath,
51
+ allInfraDirs,
52
+ tcc.cwd,
53
+ )
54
+ ) {
55
+ deps.runtime.writeReviewLog(
56
+ "permission_request.infrastructure_auto_allowed",
57
+ {
58
+ source: "tool_call",
59
+ toolCallId: tcc.toolCallId,
60
+ toolName: tcc.toolName,
61
+ agentName: tcc.agentName,
62
+ path: externalDirectoryPath,
63
+ },
64
+ );
65
+ emitDecisionEvent(deps.events, {
66
+ surface: tcc.toolName,
67
+ value: externalDirectoryPath,
68
+ result: "allow",
69
+ resolution: "infrastructure_auto_allowed",
70
+ origin: null,
71
+ agentName: tcc.agentName ?? null,
72
+ matchedPattern: null,
73
+ });
74
+ return { action: "allow" };
75
+ }
76
+
77
+ // ── Policy check ───────────────────────────────────────────────────────
78
+ const extCheck = deps.runtime.permissionManager.checkPermission(
79
+ "external_directory",
80
+ { path: normalizedExtPath },
81
+ tcc.agentName ?? undefined,
82
+ deps.runtime.sessionRules.getRuleset(),
83
+ );
84
+
85
+ // Session-rule hit
86
+ if (extCheck.source === "session") {
87
+ deps.runtime.writeReviewLog("permission_request.session_approved", {
88
+ source: "tool_call",
89
+ toolCallId: tcc.toolCallId,
90
+ toolName: tcc.toolName,
91
+ agentName: tcc.agentName,
92
+ path: externalDirectoryPath,
93
+ resolution: "session_approved",
94
+ sessionApprovalPattern: extCheck.matchedPattern,
95
+ });
96
+ emitDecisionEvent(deps.events, {
97
+ surface: "external_directory",
98
+ value: externalDirectoryPath,
99
+ result: "allow",
100
+ resolution: "session_approved",
101
+ origin: extCheck.origin ?? null,
102
+ agentName: tcc.agentName ?? null,
103
+ matchedPattern: extCheck.matchedPattern ?? null,
104
+ });
105
+ return { action: "allow" };
106
+ }
107
+
108
+ // ── Interactive gate ───────────────────────────────────────────────────
109
+ let extDirDecision: PermissionPromptDecision | null = null;
110
+ const extDirMessage = formatExternalDirectoryAskPrompt(
111
+ tcc.toolName,
112
+ externalDirectoryPath,
113
+ tcc.cwd,
114
+ tcc.agentName ?? undefined,
115
+ );
116
+ const extDirCanConfirm = deps.canRequestPermissionConfirmation(
117
+ deps.runtime.runtimeContext!,
118
+ );
119
+ const extDirGateResult = await applyPermissionGate({
120
+ state: extCheck.state,
121
+ canConfirm: extDirCanConfirm,
122
+ promptForApproval: async () => {
123
+ const decision = await deps.promptPermission(
124
+ deps.runtime.runtimeContext!,
125
+ {
126
+ requestId: tcc.toolCallId,
127
+ source: "tool_call",
128
+ agentName: tcc.agentName,
129
+ message: extDirMessage,
130
+ toolCallId: tcc.toolCallId,
131
+ toolName: tcc.toolName,
132
+ path: externalDirectoryPath,
133
+ },
134
+ );
135
+ extDirDecision = decision;
136
+ return decision;
137
+ },
138
+ writeLog: deps.runtime.writeReviewLog,
139
+ logContext: {
140
+ source: "tool_call",
141
+ toolCallId: tcc.toolCallId,
142
+ toolName: tcc.toolName,
143
+ agentName: tcc.agentName,
144
+ path: externalDirectoryPath,
145
+ message: extDirMessage,
146
+ },
147
+ messages: {
148
+ denyReason: formatExternalDirectoryDenyReason(
149
+ tcc.toolName,
150
+ externalDirectoryPath,
151
+ tcc.cwd,
152
+ tcc.agentName ?? undefined,
153
+ ),
154
+ unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
155
+ userDeniedReason: (decision) =>
156
+ formatExternalDirectoryUserDeniedReason(
157
+ tcc.toolName,
158
+ externalDirectoryPath,
159
+ decision.denialReason,
160
+ ),
161
+ },
162
+ });
163
+
164
+ emitDecisionEvent(deps.events, {
165
+ surface: "external_directory",
166
+ value: externalDirectoryPath,
167
+ result: extDirGateResult.action === "allow" ? "allow" : "deny",
168
+ resolution: deriveResolution(
169
+ extCheck.state,
170
+ extDirGateResult.action,
171
+ extDirDecision?.state === "approved_for_session",
172
+ extDirCanConfirm,
173
+ ),
174
+ origin: extCheck.origin ?? null,
175
+ agentName: tcc.agentName ?? null,
176
+ matchedPattern: extCheck.matchedPattern ?? null,
177
+ });
178
+
179
+ if (extDirGateResult.action === "block") {
180
+ return { action: "block", reason: extDirGateResult.reason };
181
+ }
182
+
183
+ if (extDirDecision?.state === "approved_for_session") {
184
+ const pattern = deriveApprovalPattern(normalizedExtPath);
185
+ deps.runtime.sessionRules.approve("external_directory", pattern);
186
+ }
187
+
188
+ return { action: "allow" };
189
+ }
@@ -0,0 +1,41 @@
1
+ import type { PermissionDecisionResolution } from "../../permission-events";
2
+ import type { PermissionCheckResult } from "../../types";
3
+
4
+ /**
5
+ * Derive the human-readable value for a decision event from a check result.
6
+ * Bash → extracted command; MCP → qualified target; others → tool name.
7
+ */
8
+ export function deriveDecisionValue(
9
+ toolName: string,
10
+ check: Pick<PermissionCheckResult, "command" | "target">,
11
+ ): string {
12
+ if (toolName === "bash") return check.command ?? toolName;
13
+ if (toolName === "mcp") return check.target ?? toolName;
14
+ return toolName;
15
+ }
16
+
17
+ /**
18
+ * Map the gate outcome back to a PermissionDecisionResolution.
19
+ *
20
+ * @param state - The permission state passed to the gate.
21
+ * @param action - The gate's resulting action ("allow" | "block").
22
+ * @param hasSession - True when the gate result carries a sessionApproval
23
+ * (indicates the user chose "for this session").
24
+ * @param canConfirm - Whether an interactive prompt was available.
25
+ */
26
+ export function deriveResolution(
27
+ state: "allow" | "deny" | "ask",
28
+ action: "allow" | "block",
29
+ hasSession: boolean,
30
+ canConfirm: boolean,
31
+ autoApproved = false,
32
+ ): PermissionDecisionResolution {
33
+ if (state === "allow") return "policy_allow";
34
+ if (state === "deny") return "policy_deny";
35
+ // state === "ask"
36
+ if (action === "allow") {
37
+ if (autoApproved) return "auto_approved";
38
+ return hasSession ? "user_approved_for_session" : "user_approved";
39
+ }
40
+ return canConfirm ? "user_denied" : "confirmation_unavailable";
41
+ }
@@ -0,0 +1,6 @@
1
+ export { evaluateBashExternalDirectoryGate } from "./bash-external-directory";
2
+ export { evaluateExternalDirectoryGate } from "./external-directory";
3
+ export { deriveDecisionValue, deriveResolution } from "./helpers";
4
+ export { evaluateSkillReadGate } from "./skill-read";
5
+ export { evaluateToolGate } from "./tool";
6
+ export type { GateOutcome, ToolCallContext } from "./types";
@@ -0,0 +1,111 @@
1
+ import { toRecord } from "../../common";
2
+ import { normalizePathForComparison } from "../../external-directory";
3
+ import { emitDecisionEvent } from "../../permission-events";
4
+ import { applyPermissionGate } from "../../permission-gate";
5
+ import {
6
+ formatSkillPathAskPrompt,
7
+ formatSkillPathDenyReason,
8
+ } from "../../permission-prompts";
9
+ import { findSkillPathMatch } from "../../skill-prompt-sanitizer";
10
+ import type { HandlerDeps } from "../types";
11
+ import { deriveResolution } from "./helpers";
12
+ import type { GateOutcome, ToolCallContext } from "./types";
13
+
14
+ /**
15
+ * Evaluate the skill-read permission gate.
16
+ *
17
+ * Returns `null` when the gate does not apply (tool is not `read`, no active
18
+ * skill entries, or the read path does not match any skill).
19
+ */
20
+ export async function evaluateSkillReadGate(
21
+ tcc: ToolCallContext,
22
+ deps: HandlerDeps,
23
+ ): Promise<GateOutcome | null> {
24
+ // Only applies to read tool calls with active skill entries
25
+ if (tcc.toolName !== "read" || deps.runtime.activeSkillEntries.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ const inputRecord = toRecord(tcc.input);
30
+ const path = typeof inputRecord.path === "string" ? inputRecord.path : "";
31
+ if (!path) {
32
+ return null;
33
+ }
34
+
35
+ const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
36
+ const matchedSkill = findSkillPathMatch(
37
+ normalizedReadPath,
38
+ deps.runtime.activeSkillEntries,
39
+ );
40
+
41
+ if (!matchedSkill) {
42
+ return null;
43
+ }
44
+
45
+ const skillReadMessage = formatSkillPathAskPrompt(
46
+ matchedSkill,
47
+ path,
48
+ tcc.agentName ?? undefined,
49
+ );
50
+ const skillReadCanConfirm = deps.canRequestPermissionConfirmation(
51
+ deps.runtime.runtimeContext!,
52
+ );
53
+ const skillReadGate = await applyPermissionGate({
54
+ state: matchedSkill.state,
55
+ canConfirm: skillReadCanConfirm,
56
+ promptForApproval: () =>
57
+ deps.promptPermission(deps.runtime.runtimeContext!, {
58
+ requestId: tcc.toolCallId,
59
+ source: "skill_read",
60
+ agentName: tcc.agentName,
61
+ message: skillReadMessage,
62
+ toolCallId: tcc.toolCallId,
63
+ toolName: tcc.toolName,
64
+ skillName: matchedSkill.name,
65
+ path,
66
+ }),
67
+ writeLog: deps.runtime.writeReviewLog,
68
+ logContext: {
69
+ source: "skill_read",
70
+ skillName: matchedSkill.name,
71
+ agentName: tcc.agentName,
72
+ path,
73
+ message: skillReadMessage,
74
+ },
75
+ messages: {
76
+ denyReason: formatSkillPathDenyReason(
77
+ matchedSkill,
78
+ path,
79
+ tcc.agentName ?? undefined,
80
+ ),
81
+ unavailableReason: `Accessing skill '${matchedSkill.name}' requires approval, but no interactive UI is available.`,
82
+ userDeniedReason: (decision) => {
83
+ const denialReason = decision.denialReason
84
+ ? ` Reason: ${decision.denialReason}.`
85
+ : "";
86
+ return `User denied access to skill '${matchedSkill.name}'.${denialReason}`;
87
+ },
88
+ },
89
+ });
90
+
91
+ emitDecisionEvent(deps.events, {
92
+ surface: "skill",
93
+ value: matchedSkill.name,
94
+ result: skillReadGate.action === "allow" ? "allow" : "deny",
95
+ resolution: deriveResolution(
96
+ matchedSkill.state,
97
+ skillReadGate.action,
98
+ false,
99
+ skillReadCanConfirm,
100
+ ),
101
+ origin: null,
102
+ agentName: tcc.agentName ?? null,
103
+ matchedPattern: null,
104
+ });
105
+
106
+ if (skillReadGate.action === "block") {
107
+ return { action: "block", reason: skillReadGate.reason };
108
+ }
109
+
110
+ return { action: "allow" };
111
+ }
@@ -0,0 +1,160 @@
1
+ import { PATH_BEARING_TOOLS } from "../../external-directory";
2
+ import { suggestSessionPattern } from "../../pattern-suggest";
3
+ import { emitDecisionEvent } from "../../permission-events";
4
+ import { applyPermissionGate } from "../../permission-gate";
5
+ import {
6
+ formatAskPrompt,
7
+ formatDenyReason,
8
+ formatUserDeniedReason,
9
+ } from "../../permission-prompts";
10
+ import { getPermissionLogContext } from "../../tool-input-preview";
11
+ import type { HandlerDeps } from "../types";
12
+ import { deriveDecisionValue, deriveResolution } from "./helpers";
13
+ import type { GateOutcome, ToolCallContext } from "./types";
14
+
15
+ /**
16
+ * Evaluate the normal tool permission gate.
17
+ *
18
+ * Unlike the other gates this one always applies — it never returns `null`.
19
+ */
20
+ export async function evaluateToolGate(
21
+ tcc: ToolCallContext,
22
+ deps: HandlerDeps,
23
+ ): Promise<GateOutcome> {
24
+ const check = deps.runtime.permissionManager.checkPermission(
25
+ tcc.toolName,
26
+ tcc.input,
27
+ tcc.agentName ?? undefined,
28
+ deps.runtime.sessionRules.getRuleset(),
29
+ );
30
+
31
+ // Session-hit: already approved by a session rule — skip the gate entirely.
32
+ if (check.source === "session") {
33
+ deps.runtime.writeReviewLog("permission_request.session_approved", {
34
+ source: "tool_call",
35
+ toolCallId: tcc.toolCallId,
36
+ toolName: tcc.toolName,
37
+ agentName: tcc.agentName,
38
+ resolution: "session_approved",
39
+ sessionApprovalPattern: check.matchedPattern,
40
+ });
41
+ emitDecisionEvent(deps.events, {
42
+ surface: tcc.toolName,
43
+ value: deriveDecisionValue(tcc.toolName, check),
44
+ result: "allow",
45
+ resolution: "session_approved",
46
+ origin: check.origin ?? null,
47
+ agentName: tcc.agentName ?? null,
48
+ matchedPattern: check.matchedPattern ?? null,
49
+ });
50
+ return { action: "allow" };
51
+ }
52
+
53
+ const permissionLogContext = getPermissionLogContext(
54
+ check,
55
+ tcc.input,
56
+ PATH_BEARING_TOOLS,
57
+ );
58
+
59
+ // Compute session approval suggestion for the "for this session" option.
60
+ const suggestionValue =
61
+ tcc.toolName === "bash"
62
+ ? (check.command ?? "")
63
+ : tcc.toolName === "mcp"
64
+ ? (check.target ?? "mcp")
65
+ : "*";
66
+ const suggestion = suggestSessionPattern(tcc.toolName, suggestionValue);
67
+
68
+ // Build the unavailable-reason message. Bash gets the command embedded.
69
+ const inputCommand =
70
+ tcc.toolName === "bash" &&
71
+ typeof (tcc.input as Record<string, unknown>)?.command === "string"
72
+ ? ((tcc.input as Record<string, unknown>).command as string)
73
+ : null;
74
+ const toolUnavailableReason = inputCommand
75
+ ? `Running bash command '${inputCommand}' requires approval, but no interactive UI is available.`
76
+ : tcc.toolName === "mcp"
77
+ ? "Using tool 'mcp' requires approval, but no interactive UI is available."
78
+ : `Using tool '${tcc.toolName}' requires approval, but no interactive UI is available.`;
79
+
80
+ const toolAskMessage = formatAskPrompt(
81
+ check,
82
+ tcc.agentName ?? undefined,
83
+ tcc.input,
84
+ );
85
+ const toolCanConfirm = deps.canRequestPermissionConfirmation(
86
+ deps.runtime.runtimeContext!,
87
+ );
88
+ let toolDecisionAutoApproved = false;
89
+ const toolGate = await applyPermissionGate({
90
+ state: check.state,
91
+ canConfirm: toolCanConfirm,
92
+ sessionApproval: {
93
+ surface: suggestion.surface,
94
+ pattern: suggestion.pattern,
95
+ },
96
+ promptForApproval: async () => {
97
+ const decision = await deps.promptPermission(
98
+ deps.runtime.runtimeContext!,
99
+ {
100
+ requestId: tcc.toolCallId,
101
+ source: "tool_call",
102
+ agentName: tcc.agentName,
103
+ message: toolAskMessage,
104
+ toolCallId: tcc.toolCallId,
105
+ toolName: tcc.toolName,
106
+ sessionLabel: suggestion.label,
107
+ ...permissionLogContext,
108
+ },
109
+ );
110
+ toolDecisionAutoApproved = decision.autoApproved === true;
111
+ return decision;
112
+ },
113
+ writeLog: deps.runtime.writeReviewLog,
114
+ logContext: {
115
+ source: "tool_call",
116
+ toolCallId: tcc.toolCallId,
117
+ toolName: tcc.toolName,
118
+ agentName: tcc.agentName,
119
+ message: toolAskMessage,
120
+ ...permissionLogContext,
121
+ },
122
+ messages: {
123
+ denyReason: formatDenyReason(check, tcc.agentName ?? undefined),
124
+ unavailableReason: toolUnavailableReason,
125
+ userDeniedReason: (decision) =>
126
+ formatUserDeniedReason(check, decision.denialReason),
127
+ },
128
+ });
129
+
130
+ const toolGateHasSession =
131
+ toolGate.action === "allow" && toolGate.sessionApproval !== undefined;
132
+ emitDecisionEvent(deps.events, {
133
+ surface: tcc.toolName,
134
+ value: deriveDecisionValue(tcc.toolName, check),
135
+ result: toolGate.action === "allow" ? "allow" : "deny",
136
+ resolution: deriveResolution(
137
+ check.state,
138
+ toolGate.action,
139
+ toolGateHasSession,
140
+ toolCanConfirm,
141
+ toolDecisionAutoApproved,
142
+ ),
143
+ origin: check.origin ?? null,
144
+ agentName: tcc.agentName ?? null,
145
+ matchedPattern: check.matchedPattern ?? null,
146
+ });
147
+
148
+ if (toolGate.action === "block") {
149
+ return { action: "block", reason: toolGate.reason };
150
+ }
151
+
152
+ if (toolGate.sessionApproval) {
153
+ deps.runtime.sessionRules.approve(
154
+ toolGate.sessionApproval.surface,
155
+ toolGate.sessionApproval.pattern,
156
+ );
157
+ }
158
+
159
+ return { action: "allow" };
160
+ }