@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 +24 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +134 -0
- package/src/handlers/gates/external-directory.ts +189 -0
- package/src/handlers/gates/helpers.ts +41 -0
- package/src/handlers/gates/index.ts +6 -0
- package/src/handlers/gates/skill-read.ts +111 -0
- package/src/handlers/gates/tool.ts +160 -0
- package/src/handlers/gates/types.ts +15 -0
- package/src/handlers/tool-call.ts +33 -523
- package/tests/handlers/gates/bash-external-directory.test.ts +247 -0
- package/tests/handlers/gates/external-directory.test.ts +320 -0
- package/tests/handlers/gates/helpers.test.ts +71 -0
- package/tests/handlers/gates/skill-read.test.ts +204 -0
- package/tests/handlers/gates/tool.test.ts +417 -0
- package/tests/handlers/tool-call.test.ts +0 -504
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
package/package.json
CHANGED
|
@@ -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
|
+
}
|