pi-codex-auto-review 0.1.4 → 0.2.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/NOTICE +4 -2
- package/README.ko.md +85 -0
- package/README.md +51 -52
- package/dist/approval-commands.d.ts +3 -3
- package/dist/approval-commands.js +112 -58
- package/dist/approval-commands.js.map +1 -1
- package/dist/approval-settings.d.ts +1 -1
- package/dist/approval-settings.js +17 -10
- package/dist/approval-settings.js.map +1 -1
- package/dist/approvals.d.ts +9 -9
- package/dist/approvals.js +195 -75
- package/dist/approvals.js.map +1 -1
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +37 -13
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +60 -31
- package/dist/cli.js.map +1 -1
- package/dist/context-files.d.ts +2 -2
- package/dist/context-files.js +40 -19
- package/dist/context-files.js.map +1 -1
- package/dist/contracts.d.ts +39 -39
- package/dist/contracts.js +117 -51
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.js +145 -48
- package/dist/index.js.map +1 -1
- package/dist/pi-host.js +3 -3
- package/dist/pi-host.js.map +1 -1
- package/dist/policy/domains.js +17 -7
- package/dist/policy/domains.js.map +1 -1
- package/dist/policy/index.d.ts +7 -7
- package/dist/policy/index.js +379 -141
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/paths.js +31 -18
- package/dist/policy/paths.js.map +1 -1
- package/dist/policy/rule-engine.d.ts +2 -0
- package/dist/policy/rule-engine.js +283 -0
- package/dist/policy/rule-engine.js.map +1 -0
- package/dist/policy/rule-types.d.ts +30 -0
- package/dist/policy/rule-types.js +2 -0
- package/dist/policy/rule-types.js.map +1 -0
- package/dist/policy/rules-sync.d.ts +1 -0
- package/dist/policy/rules-sync.js +14 -0
- package/dist/policy/rules-sync.js.map +1 -0
- package/dist/policy/rules-worker.d.ts +1 -0
- package/dist/policy/rules-worker.js +14 -0
- package/dist/policy/rules-worker.js.map +1 -0
- package/dist/policy/rules.d.ts +4 -31
- package/dist/policy/rules.js +126 -38
- package/dist/policy/rules.js.map +1 -1
- package/dist/policy/shell.js +70 -41
- package/dist/policy/shell.js.map +1 -1
- package/dist/policy/starlark/builtins.d.ts +6 -0
- package/dist/policy/starlark/builtins.js +764 -0
- package/dist/policy/starlark/builtins.js.map +1 -0
- package/dist/policy/starlark/evaluator.d.ts +18 -0
- package/dist/policy/starlark/evaluator.js +518 -0
- package/dist/policy/starlark/evaluator.js.map +1 -0
- package/dist/policy/starlark/lexer.d.ts +14 -0
- package/dist/policy/starlark/lexer.js +189 -0
- package/dist/policy/starlark/lexer.js.map +1 -0
- package/dist/policy/starlark/parser.d.ts +140 -0
- package/dist/policy/starlark/parser.js +508 -0
- package/dist/policy/starlark/parser.js.map +1 -0
- package/dist/policy/starlark/validation.d.ts +4 -0
- package/dist/policy/starlark/validation.js +183 -0
- package/dist/policy/starlark/validation.js.map +1 -0
- package/dist/policy/starlark/values.d.ts +46 -0
- package/dist/policy/starlark/values.js +363 -0
- package/dist/policy/starlark/values.js.map +1 -0
- package/dist/reports.d.ts +1 -1
- package/dist/reports.js +66 -30
- package/dist/reports.js.map +1 -1
- package/dist/review/context.d.ts +3 -3
- package/dist/review/context.js +207 -71
- package/dist/review/context.js.map +1 -1
- package/dist/review/investigation.d.ts +2 -2
- package/dist/review/investigation.js +57 -16
- package/dist/review/investigation.js.map +1 -1
- package/dist/review/lifecycle.d.ts +1 -1
- package/dist/review/lifecycle.js +70 -27
- package/dist/review/lifecycle.js.map +1 -1
- package/dist/review/policy.js +10 -5
- package/dist/review/policy.js.map +1 -1
- package/dist/review/upstream-policy.js +1 -1
- package/dist/review/upstream-policy.js.map +1 -1
- package/dist/review/user-input.d.ts +3 -3
- package/dist/review/user-input.js +39 -16
- package/dist/review/user-input.js.map +1 -1
- package/dist/reviewer.d.ts +8 -8
- package/dist/reviewer.js +253 -75
- package/dist/reviewer.js.map +1 -1
- package/dist/signals.js +24 -9
- package/dist/signals.js.map +1 -1
- package/dist/startup.d.ts +4 -4
- package/dist/startup.js +139 -41
- package/dist/startup.js.map +1 -1
- package/dist/tools/controller.d.ts +16 -12
- package/dist/tools/controller.js +432 -121
- package/dist/tools/controller.js.map +1 -1
- package/dist/tools/environment.js +3 -1
- package/dist/tools/environment.js.map +1 -1
- package/dist/tools/executor.d.ts +1 -1
- package/dist/tools/executor.js +45 -28
- package/dist/tools/executor.js.map +1 -1
- package/dist/tools/external.d.ts +8 -8
- package/dist/tools/external.js +22 -11
- package/dist/tools/external.js.map +1 -1
- package/dist/tools/mcp.d.ts +6 -6
- package/dist/tools/mcp.js +208 -80
- package/dist/tools/mcp.js.map +1 -1
- package/dist/tools/mutation-queue.js.map +1 -1
- package/docs/publishing.ko.md +114 -0
- package/docs/publishing.md +49 -47
- package/docs/security/dependencies.ko.md +54 -0
- package/docs/security/dependencies.md +27 -26
- package/docs/testing/auto-review-protection.ko.md +158 -0
- package/docs/testing/auto-review-protection.md +104 -99
- package/docs/usage.ko.md +242 -0
- package/docs/usage.md +125 -121
- package/package.json +106 -95
- package/dist/native/darwin-arm64/artifact.json +0 -5
- package/dist/native/darwin-arm64/pi-guard-execpolicy +0 -0
- package/dist/native/linux-x64/artifact.json +0 -5
- package/dist/native/linux-x64/pi-guard-execpolicy +0 -0
- package/native/execpolicy/LICENSE +0 -201
- package/native/execpolicy/NOTICE +0 -6
package/dist/tools/controller.js
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
import { randomUUID } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { createBashToolDefinition, } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { redact } from "../audit.js";
|
|
5
|
+
import { canonicalJson, createAction, digest, GuardError, TOOL_NAMES, } from "../contracts.js";
|
|
6
|
+
import { canonicalPath, PolicyEngine, resolveToolPath, } from "../policy/index.js";
|
|
7
|
+
import { ReviewContextStore, safeEvidence } from "../review/context.js";
|
|
8
|
+
import { LocalInvestigation } from "../review/investigation.js";
|
|
9
|
+
import { reviewFeedback } from "../review/lifecycle.js";
|
|
10
|
+
import { observeToolUserInput } from "../review/user-input.js";
|
|
11
|
+
import { PiReviewProvider, } from "../reviewer.js";
|
|
12
|
+
import { redactedEnvironmentNames, reviewEnvironment } from "./environment.js";
|
|
13
|
+
import { externalInvocations, externalPolicy, } from "./external.js";
|
|
14
|
+
import { withApprovalMutationQueue } from "./mutation-queue.js";
|
|
15
15
|
export class GuardController {
|
|
16
16
|
options;
|
|
17
17
|
currentPolicy;
|
|
18
|
-
get policy() {
|
|
18
|
+
get policy() {
|
|
19
|
+
return this.currentPolicy;
|
|
20
|
+
}
|
|
19
21
|
approvals;
|
|
20
22
|
sources = new Map();
|
|
21
23
|
ownedOperations = new WeakSet();
|
|
22
24
|
stopped = new AbortController();
|
|
23
|
-
sessionId =
|
|
25
|
+
sessionId = "not-started";
|
|
24
26
|
isReady = false;
|
|
25
27
|
reviewContext;
|
|
26
28
|
activeReviews = new Set();
|
|
@@ -48,26 +50,32 @@ export class GuardController {
|
|
|
48
50
|
await policy.initialize(cwd);
|
|
49
51
|
this.assertReady();
|
|
50
52
|
await persist(settings);
|
|
51
|
-
this.stopped.abort(new GuardError(
|
|
53
|
+
this.stopped.abort(new GuardError("SETTINGS_CHANGED", "Approval settings changed"));
|
|
52
54
|
this.stopped = new AbortController();
|
|
53
55
|
this.approvals.invalidate();
|
|
54
56
|
this.activeReviews.clear();
|
|
55
57
|
this.retryArguments.clear();
|
|
56
58
|
this.options.settings = settings;
|
|
57
59
|
Object.assign(this.approvals.options, {
|
|
58
|
-
approvalsReviewer: settings.approvalsReviewer,
|
|
59
|
-
|
|
60
|
+
approvalsReviewer: settings.approvalsReviewer,
|
|
61
|
+
approvalPolicy: settings.approvalPolicy,
|
|
62
|
+
reviewTimeoutMs: settings.reviewTimeoutMs,
|
|
63
|
+
approvalTimeoutMs: settings.approvalTimeoutMs,
|
|
60
64
|
});
|
|
61
65
|
this.currentPolicy = policy;
|
|
62
66
|
});
|
|
63
67
|
this.settingsUpdates = task.catch(() => { });
|
|
64
68
|
await task;
|
|
65
69
|
}
|
|
66
|
-
assertReady() {
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
assertReady() {
|
|
71
|
+
if (!this.isReady || this.stopped.signal.aborted)
|
|
72
|
+
throw new GuardError("GUARD_NOT_READY", "Guard is not ready for execution");
|
|
73
|
+
}
|
|
74
|
+
isBoundToSession(sessionId) {
|
|
75
|
+
return this.sessionId === sessionId && !this.stopped.signal.aborted;
|
|
76
|
+
}
|
|
69
77
|
reset(sessionId, manager) {
|
|
70
|
-
this.stopped.abort(new GuardError(
|
|
78
|
+
this.stopped.abort(new GuardError("SESSION_CHANGED", "Session changed"));
|
|
71
79
|
this.stopped = new AbortController();
|
|
72
80
|
this.sessionId = sessionId;
|
|
73
81
|
this.reviewContext.reset(sessionId, manager);
|
|
@@ -86,25 +94,50 @@ export class GuardController {
|
|
|
86
94
|
}
|
|
87
95
|
async authorizeRetry(id, context) {
|
|
88
96
|
this.assertReady();
|
|
89
|
-
const denial = this.approvals.lifecycle.authorizeRetry(id, {
|
|
97
|
+
const denial = this.approvals.lifecycle.authorizeRetry(id, {
|
|
98
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
99
|
+
contextId: this.reviewContext.identity,
|
|
100
|
+
cwd: await canonicalPath(context.cwd, context.cwd),
|
|
101
|
+
policyRevision: this.policy.revision,
|
|
102
|
+
permissionDigest: digest(this.policy.profile),
|
|
103
|
+
});
|
|
90
104
|
const args = this.retryArguments.get(id) ?? denial.action.args;
|
|
91
105
|
this.retryArguments.delete(id);
|
|
92
106
|
return { denial, args };
|
|
93
107
|
}
|
|
94
108
|
async retryUserBash(action, context) {
|
|
95
109
|
const next = createAction({ ...action, toolCallId: randomUUID() }, this.policy.profile);
|
|
96
|
-
const command = String(next.args.command), shellPath = String(next.args.shellPath ??
|
|
97
|
-
await this.admitAndExecute(next, { kind:
|
|
110
|
+
const command = String(next.args.command), shellPath = String(next.args.shellPath ?? "/bin/bash");
|
|
111
|
+
await this.admitAndExecute(next, { kind: "shell", command, shellPath, cwd: next.cwd }, context, {
|
|
112
|
+
timeoutSeconds: typeof next.args.timeout === "number"
|
|
113
|
+
? next.args.timeout
|
|
114
|
+
: undefined,
|
|
115
|
+
env: next.args.environment,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
authorizeUser(text) {
|
|
119
|
+
this.reviewContext.authorize(text);
|
|
120
|
+
this.approvals.lifecycle.invalidateRetries();
|
|
98
121
|
}
|
|
99
|
-
authorizeUser(text) { this.reviewContext.authorize(text); this.approvals.lifecycle.invalidateRetries(); }
|
|
100
122
|
noteCall(event) {
|
|
101
|
-
this.sources.set(event.toolCallId, event.parentToolCallId ?
|
|
123
|
+
this.sources.set(event.toolCallId, event.parentToolCallId ? "nested" : "model");
|
|
102
124
|
const callIdentity = this.reviewContext.callIdentity(event.toolCallId);
|
|
103
|
-
this.reviewContext.toolCall({
|
|
104
|
-
|
|
125
|
+
this.reviewContext.toolCall({
|
|
126
|
+
tool: event.toolName,
|
|
127
|
+
toolCallId: event.toolCallId,
|
|
128
|
+
callIdentity,
|
|
129
|
+
args: JSON.parse(canonicalJson(event.input)),
|
|
130
|
+
...(event.parentToolCallId
|
|
131
|
+
? {
|
|
132
|
+
parentToolCallId: event.parentToolCallId,
|
|
133
|
+
parentCallIdentity: this.reviewContext.callIdentity(event.parentToolCallId),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
105
136
|
}, callIdentity);
|
|
106
137
|
}
|
|
107
|
-
completeCall(toolCallId) {
|
|
138
|
+
completeCall(toolCallId) {
|
|
139
|
+
this.sources.delete(toolCallId);
|
|
140
|
+
}
|
|
108
141
|
async close() {
|
|
109
142
|
this.isReady = false;
|
|
110
143
|
this.stopped.abort();
|
|
@@ -115,40 +148,88 @@ export class GuardController {
|
|
|
115
148
|
await this.settingsUpdates;
|
|
116
149
|
}
|
|
117
150
|
ui(context, isOnceOnly = false) {
|
|
118
|
-
if (!context.hasUI ||
|
|
151
|
+
if (!context.hasUI ||
|
|
152
|
+
(context.mode !== "tui" && context.mode !== "rpc"))
|
|
119
153
|
return undefined;
|
|
120
|
-
const scopes = {
|
|
121
|
-
|
|
154
|
+
const scopes = {
|
|
155
|
+
"Allow once": "once",
|
|
156
|
+
"Allow for this session": "session",
|
|
157
|
+
"Save as an allow rule": "persistent",
|
|
158
|
+
Deny: "deny",
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
select: async (action, delta, options) => {
|
|
122
162
|
const text = `Tool: ${action.tool}\nWorking directory: ${action.cwd}\nInput: ${canonicalJson(action.args)}\nRequested scope: ${canonicalJson(delta)}`;
|
|
123
|
-
const selected = await context.ui.select(`Approve this action?\n${text}`, isOnceOnly ? [
|
|
163
|
+
const selected = await context.ui.select(`Approve this action?\n${text}`, isOnceOnly ? ["Allow once", "Deny"] : Object.keys(scopes), { signal: options.signal, timeout: options.timeoutMs });
|
|
124
164
|
if (selected && selected in scopes)
|
|
125
165
|
this.reviewContext.confirm({
|
|
126
|
-
actionDigest: action.digest,
|
|
166
|
+
actionDigest: action.digest,
|
|
167
|
+
action: JSON.parse(canonicalJson(action)),
|
|
168
|
+
requestedScope: JSON.parse(canonicalJson(delta)),
|
|
169
|
+
choice: selected,
|
|
170
|
+
scope: scopes[selected],
|
|
127
171
|
});
|
|
128
|
-
return selected
|
|
129
|
-
|
|
172
|
+
return selected
|
|
173
|
+
? scopes[selected]
|
|
174
|
+
: undefined;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
130
177
|
}
|
|
131
|
-
async requestAdmission(action, policyDecision, context, signal, trustedAuthorization =
|
|
178
|
+
async requestAdmission(action, policyDecision, context, signal, trustedAuthorization = "") {
|
|
132
179
|
this.reviewContext.refreshInstructions();
|
|
133
180
|
if (context.getSystemPrompt)
|
|
134
181
|
this.reviewContext.mainPrompt(context.getSystemPrompt());
|
|
135
182
|
this.reviewContext.preparedAction(action);
|
|
136
183
|
const executionContext = {
|
|
137
|
-
environmentId:
|
|
138
|
-
|
|
184
|
+
environmentId: "local",
|
|
185
|
+
platform: process.platform,
|
|
186
|
+
architecture: process.arch,
|
|
187
|
+
osIsolation: false,
|
|
188
|
+
permissionProfile: this.policy.profile,
|
|
189
|
+
approvalPolicy: this.options.settings.approvalPolicy,
|
|
190
|
+
approvalsReviewer: this.options.settings.approvalsReviewer,
|
|
139
191
|
};
|
|
140
|
-
let reviewContext = policyDecision.kind ===
|
|
192
|
+
let reviewContext = policyDecision.kind === "ask"
|
|
193
|
+
? this.reviewContext.snapshot(this.options.settings.reviewContextChars, canonicalJson({
|
|
194
|
+
action,
|
|
195
|
+
delta: policyDecision.delta,
|
|
196
|
+
executionContext,
|
|
197
|
+
policyReason: policyDecision.reason,
|
|
198
|
+
}).length + 1000)
|
|
199
|
+
: undefined;
|
|
141
200
|
if (reviewContext && trustedAuthorization) {
|
|
142
201
|
const { digest: previousDigest, ...fields } = reviewContext;
|
|
143
|
-
const data = {
|
|
202
|
+
const data = {
|
|
203
|
+
...fields,
|
|
204
|
+
items: [
|
|
205
|
+
...reviewContext.items,
|
|
206
|
+
{
|
|
207
|
+
id: "direct-user-bash",
|
|
208
|
+
source: "user",
|
|
209
|
+
trust: "authorization",
|
|
210
|
+
content: safeEvidence(trustedAuthorization),
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
};
|
|
144
214
|
reviewContext = { ...data, digest: digest(data) };
|
|
145
215
|
}
|
|
146
216
|
const turnIdentity = this.reviewContext.turnIdentity;
|
|
147
217
|
return this.approvals.admit(action, policyDecision, {
|
|
148
|
-
provider: this.options.provider ??
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
218
|
+
provider: this.options.provider ??
|
|
219
|
+
new PiReviewProvider(context, this.options.settings, new LocalInvestigation(this.options.executor, this.policy.profile, action.cwd)),
|
|
220
|
+
ui: this.ui(context, policyDecision.requiresFreshReview ||
|
|
221
|
+
policyDecision.requiresUserInput),
|
|
222
|
+
trustedAuthorization,
|
|
223
|
+
signal,
|
|
224
|
+
reviewContext,
|
|
225
|
+
settings: this.options.settings,
|
|
226
|
+
executionContext,
|
|
227
|
+
onReviewStart: () => {
|
|
228
|
+
this.activeReviews.add(action.digest);
|
|
229
|
+
if (context.hasUI)
|
|
230
|
+
context.ui.setStatus("auto-review", `Auto-review running (${this.activeReviews.size})`);
|
|
231
|
+
},
|
|
232
|
+
onReviewResult: (result) => {
|
|
152
233
|
this.activeReviews.delete(action.digest);
|
|
153
234
|
if (turnIdentity !== this.reviewContext.turnIdentity)
|
|
154
235
|
return;
|
|
@@ -156,19 +237,34 @@ export class GuardController {
|
|
|
156
237
|
this.reviewContext.assessment(action, result);
|
|
157
238
|
if (!context.hasUI)
|
|
158
239
|
return;
|
|
159
|
-
const labels = {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
240
|
+
const labels = {
|
|
241
|
+
approved: "approved",
|
|
242
|
+
denied: "denied",
|
|
243
|
+
"timed-out": "timed out",
|
|
244
|
+
aborted: "cancelled",
|
|
245
|
+
failed: "failed",
|
|
246
|
+
};
|
|
247
|
+
context.ui.setStatus("auto-review", this.activeReviews.size
|
|
248
|
+
? `Auto-review running (${this.activeReviews.size})`
|
|
249
|
+
: result
|
|
250
|
+
? `Auto-review: ${labels[result.status]}`
|
|
251
|
+
: undefined);
|
|
252
|
+
if (result &&
|
|
253
|
+
result.status !== "approved" &&
|
|
254
|
+
result.status !== "aborted")
|
|
255
|
+
context.ui.notify(`Auto-review ${labels[result.status]}: ${redact("assessment" in result ? result.assessment.rationale : result.reason).slice(0, 1000)}`, "warning");
|
|
256
|
+
},
|
|
257
|
+
onInterrupt: () => {
|
|
258
|
+
if (context.hasUI)
|
|
259
|
+
context.ui.notify("Auto-review stopped this turn after repeated denials.", "warning");
|
|
260
|
+
context.abort?.();
|
|
163
261
|
},
|
|
164
|
-
onInterrupt: () => { if (context.hasUI)
|
|
165
|
-
context.ui.notify('Auto-review stopped this turn after repeated denials.', 'warning'); context.abort?.(); },
|
|
166
262
|
});
|
|
167
263
|
}
|
|
168
264
|
denied(admission, retryArguments) {
|
|
169
265
|
if (admission.denialId && retryArguments)
|
|
170
266
|
this.retryArguments.set(admission.denialId, retryArguments);
|
|
171
|
-
const retained = new Set(this.approvals.lifecycle.recentDenials.map(item => item.id));
|
|
267
|
+
const retained = new Set(this.approvals.lifecycle.recentDenials.map((item) => item.id));
|
|
172
268
|
for (const id of this.retryArguments.keys())
|
|
173
269
|
if (!retained.has(id))
|
|
174
270
|
this.retryArguments.delete(id);
|
|
@@ -176,34 +272,78 @@ export class GuardController {
|
|
|
176
272
|
const feedback = reviewFeedback(admission.review);
|
|
177
273
|
return new GuardError(feedback.code, `[${feedback.code}] ${feedback.message}`);
|
|
178
274
|
}
|
|
179
|
-
return new GuardError(
|
|
275
|
+
return new GuardError("PERMISSION_DENIED", admission.reason);
|
|
276
|
+
}
|
|
277
|
+
isExternalTool(name) {
|
|
278
|
+
return this.externalTools.get(name)?.isEnabled === true;
|
|
180
279
|
}
|
|
181
|
-
isExternalTool(name) { return this.externalTools.get(name)?.isEnabled === true; }
|
|
182
280
|
wrapExternalTool(definition, identify) {
|
|
183
|
-
if (TOOL_NAMES.includes(definition.name) ||
|
|
184
|
-
|
|
281
|
+
if (TOOL_NAMES.includes(definition.name) ||
|
|
282
|
+
definition.name === "codemode")
|
|
283
|
+
throw new GuardError("RESERVED_TOOL", "An external adapter cannot replace guarded local tools");
|
|
185
284
|
const token = randomUUID(), execute = definition.execute;
|
|
186
|
-
this.externalTools.set(definition.name, {
|
|
187
|
-
|
|
188
|
-
|
|
285
|
+
this.externalTools.set(definition.name, {
|
|
286
|
+
token,
|
|
287
|
+
isEnabled: definition.exposure !== "hidden",
|
|
288
|
+
});
|
|
289
|
+
const schema = JSON.parse(JSON.stringify({
|
|
290
|
+
parameters: definition.parameters,
|
|
291
|
+
description: definition.description,
|
|
292
|
+
annotations: definition.annotations ?? {},
|
|
293
|
+
...(definition.namespace
|
|
294
|
+
? { namespace: definition.namespace }
|
|
295
|
+
: {}),
|
|
296
|
+
}));
|
|
297
|
+
return {
|
|
298
|
+
...definition,
|
|
299
|
+
execute: async (toolCallId, params, callerSignal, onUpdate, context) => {
|
|
189
300
|
this.assertReady();
|
|
190
301
|
const lifetime = new AbortController();
|
|
191
|
-
const signal = AbortSignal.any([
|
|
302
|
+
const signal = AbortSignal.any([
|
|
303
|
+
lifetime.signal,
|
|
304
|
+
this.stopped.signal,
|
|
305
|
+
this.approvals.lifecycle.signal,
|
|
306
|
+
...(callerSignal ? [callerSignal] : []),
|
|
307
|
+
]);
|
|
192
308
|
signal.throwIfAborted();
|
|
193
309
|
const args = JSON.parse(canonicalJson(params ?? {}));
|
|
194
310
|
const identity = JSON.parse(JSON.stringify(identify()));
|
|
195
311
|
const identityDigest = digest(identity), cwd = await canonicalPath(context.cwd, context.cwd);
|
|
196
|
-
const action = createAction({
|
|
197
|
-
|
|
198
|
-
|
|
312
|
+
const action = createAction({
|
|
313
|
+
toolCallId,
|
|
314
|
+
tool: definition.name,
|
|
315
|
+
args: {
|
|
316
|
+
arguments: args,
|
|
317
|
+
externalTool: JSON.parse(JSON.stringify(identity)),
|
|
318
|
+
schema,
|
|
319
|
+
...(identity.connectedAccountEmail
|
|
320
|
+
? {
|
|
321
|
+
connected_account_email: identity.connectedAccountEmail,
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
},
|
|
325
|
+
cwd,
|
|
326
|
+
source: this.sources.get(toolCallId) ?? "model",
|
|
327
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
328
|
+
policyRevision: this.policy.revision,
|
|
329
|
+
}, this.policy.profile);
|
|
199
330
|
const authorizationVersion = this.reviewContext.scopeVersion, turn = this.reviewContext.turnIdentity;
|
|
200
331
|
const checkCurrent = async () => {
|
|
201
332
|
this.assertReady();
|
|
202
333
|
signal.throwIfAborted();
|
|
203
|
-
if (this.externalTools.get(definition.name)?.token !==
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
334
|
+
if (this.externalTools.get(definition.name)?.token !==
|
|
335
|
+
token ||
|
|
336
|
+
!this.isExternalTool(definition.name) ||
|
|
337
|
+
digest(JSON.parse(JSON.stringify(identify()))) !==
|
|
338
|
+
identityDigest)
|
|
339
|
+
throw new GuardError("STALE_EXTERNAL_TOOL", "External registration changed; a fresh action must be reviewed");
|
|
340
|
+
if (this.reviewContext.scopeVersion !==
|
|
341
|
+
authorizationVersion ||
|
|
342
|
+
this.reviewContext.turnIdentity !== turn ||
|
|
343
|
+
this.sessionId !== action.sessionId ||
|
|
344
|
+
this.policy.revision !== action.policyRevision ||
|
|
345
|
+
(await canonicalPath(context.cwd, context.cwd)) !== cwd)
|
|
346
|
+
throw new GuardError("STALE_APPROVAL", "External execution context changed after admission");
|
|
207
347
|
signal.throwIfAborted();
|
|
208
348
|
};
|
|
209
349
|
const approve = async (candidate, candidateIdentity) => {
|
|
@@ -215,12 +355,36 @@ export class GuardController {
|
|
|
215
355
|
};
|
|
216
356
|
try {
|
|
217
357
|
await approve(action, identity);
|
|
218
|
-
const result = await externalInvocations.run({
|
|
219
|
-
|
|
358
|
+
const result = await externalInvocations.run({
|
|
359
|
+
toolCallId,
|
|
360
|
+
identity,
|
|
361
|
+
arguments: args,
|
|
362
|
+
signal,
|
|
363
|
+
checkCurrent,
|
|
364
|
+
approveNested: async (nestedIdentity, nestedArgs) => {
|
|
365
|
+
if (nestedIdentity.server !== identity.server ||
|
|
366
|
+
nestedIdentity.registration !==
|
|
367
|
+
identity.registration)
|
|
220
368
|
return false;
|
|
221
|
-
const nested = createAction({
|
|
222
|
-
|
|
223
|
-
|
|
369
|
+
const nested = createAction({
|
|
370
|
+
toolCallId: `${toolCallId}:elicitation:${randomUUID()}`,
|
|
371
|
+
tool: definition.name,
|
|
372
|
+
args: {
|
|
373
|
+
arguments: nestedArgs,
|
|
374
|
+
externalTool: JSON.parse(JSON.stringify(nestedIdentity)),
|
|
375
|
+
schema,
|
|
376
|
+
originatingActionDigest: action.digest,
|
|
377
|
+
...(nestedIdentity.connectedAccountEmail
|
|
378
|
+
? {
|
|
379
|
+
connected_account_email: nestedIdentity.connectedAccountEmail,
|
|
380
|
+
}
|
|
381
|
+
: {}),
|
|
382
|
+
},
|
|
383
|
+
cwd,
|
|
384
|
+
source: "nested",
|
|
385
|
+
sessionId: action.sessionId,
|
|
386
|
+
policyRevision: action.policyRevision,
|
|
387
|
+
}, this.policy.profile);
|
|
224
388
|
try {
|
|
225
389
|
await approve(nested, nestedIdentity);
|
|
226
390
|
return true;
|
|
@@ -228,23 +392,31 @@ export class GuardController {
|
|
|
228
392
|
catch {
|
|
229
393
|
return false;
|
|
230
394
|
}
|
|
231
|
-
}
|
|
232
|
-
|
|
395
|
+
},
|
|
396
|
+
}, () => execute.call(definition, toolCallId, args, signal, onUpdate, observeToolUserInput(context, action, this.reviewContext, signal)));
|
|
397
|
+
await this.options.audit.record(action, "execution", "settled");
|
|
233
398
|
return result;
|
|
234
399
|
}
|
|
235
400
|
catch (error) {
|
|
236
|
-
await this.options.audit.record(action,
|
|
401
|
+
await this.options.audit.record(action, "execution", "failed");
|
|
237
402
|
throw error;
|
|
238
403
|
}
|
|
239
404
|
finally {
|
|
240
405
|
lifetime.abort();
|
|
241
406
|
this.completeCall(toolCallId);
|
|
242
407
|
}
|
|
243
|
-
}
|
|
408
|
+
},
|
|
409
|
+
};
|
|
244
410
|
}
|
|
245
|
-
async admitAndExecute(action, job, context, options, trustedAuthorization =
|
|
411
|
+
async admitAndExecute(action, job, context, options, trustedAuthorization = "", retryArguments) {
|
|
246
412
|
this.assertReady();
|
|
247
|
-
const signal = AbortSignal.any([
|
|
413
|
+
const signal = AbortSignal.any([
|
|
414
|
+
this.stopped.signal,
|
|
415
|
+
...(action.source === "user-bash"
|
|
416
|
+
? []
|
|
417
|
+
: [this.approvals.lifecycle.signal]),
|
|
418
|
+
...(options.signal ? [options.signal] : []),
|
|
419
|
+
]);
|
|
248
420
|
const authorizationVersion = this.reviewContext.scopeVersion;
|
|
249
421
|
const policyDecision = await this.policy.evaluate(action, signal);
|
|
250
422
|
const admission = await this.requestAdmission(action, policyDecision, context, signal, trustedAuthorization);
|
|
@@ -252,25 +424,32 @@ export class GuardController {
|
|
|
252
424
|
throw this.denied(admission, retryArguments);
|
|
253
425
|
signal.throwIfAborted();
|
|
254
426
|
if (this.reviewContext.scopeVersion !== authorizationVersion)
|
|
255
|
-
throw new GuardError(
|
|
256
|
-
if (this.sessionId !== action.sessionId ||
|
|
257
|
-
|
|
427
|
+
throw new GuardError("STALE_AUTHORIZATION", "User authorization changed during review; request a fresh review");
|
|
428
|
+
if (this.sessionId !== action.sessionId ||
|
|
429
|
+
(await canonicalPath(context.cwd, context.cwd)) !== action.cwd ||
|
|
430
|
+
this.policy.revision !== action.policyRevision)
|
|
431
|
+
throw new GuardError("STALE_APPROVAL", "Execution context changed after admission");
|
|
258
432
|
const finalDecision = await this.policy.evaluate(action, signal);
|
|
259
|
-
if (finalDecision.kind ===
|
|
260
|
-
throw new GuardError(
|
|
433
|
+
if (finalDecision.kind === "deny")
|
|
434
|
+
throw new GuardError("PERMISSION_DENIED", finalDecision.reason);
|
|
261
435
|
if (digest(finalDecision.delta) !== digest(policyDecision.delta))
|
|
262
|
-
throw new GuardError(
|
|
263
|
-
if (digest(finalDecision.authority ?? null) !==
|
|
264
|
-
|
|
436
|
+
throw new GuardError("STALE_APPROVAL", "Resolved permissions changed after admission");
|
|
437
|
+
if (digest(finalDecision.authority ?? null) !==
|
|
438
|
+
digest(policyDecision.authority ?? null))
|
|
439
|
+
throw new GuardError("STALE_APPROVAL", "Execution authority changed after admission");
|
|
265
440
|
try {
|
|
266
441
|
const result = await this.options.executor.execute(job, this.policy.profile, admission.delta, {
|
|
267
|
-
...options,
|
|
442
|
+
...options,
|
|
443
|
+
signal,
|
|
444
|
+
authority: admission.authority,
|
|
445
|
+
timeoutSeconds: options.timeoutSeconds ??
|
|
446
|
+
this.options.settings.executionTimeoutSeconds,
|
|
268
447
|
});
|
|
269
|
-
await this.options.audit.record(action,
|
|
448
|
+
await this.options.audit.record(action, "execution", "settled");
|
|
270
449
|
return result;
|
|
271
450
|
}
|
|
272
451
|
catch (error) {
|
|
273
|
-
await this.options.audit.record(action,
|
|
452
|
+
await this.options.audit.record(action, "execution", "failed");
|
|
274
453
|
throw error;
|
|
275
454
|
}
|
|
276
455
|
finally {
|
|
@@ -280,54 +459,186 @@ export class GuardController {
|
|
|
280
459
|
wrapTool(definition, toolOptions) {
|
|
281
460
|
const tool = definition.name;
|
|
282
461
|
if (!TOOL_NAMES.includes(tool))
|
|
283
|
-
throw new GuardError(
|
|
284
|
-
const parameters = tool ===
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
462
|
+
throw new GuardError("UNKNOWN_LOCAL_TOOL", "Cannot delegate unknown local tool");
|
|
463
|
+
const parameters = tool === "bash"
|
|
464
|
+
? {
|
|
465
|
+
...definition.parameters,
|
|
466
|
+
properties: {
|
|
467
|
+
...definition.parameters.properties,
|
|
468
|
+
sandbox_permissions: {
|
|
469
|
+
type: "string",
|
|
470
|
+
enum: ["use_default", "require_escalated"],
|
|
471
|
+
description: "Compatibility field: request approval review for this exact command.",
|
|
472
|
+
},
|
|
473
|
+
additional_permissions: {
|
|
474
|
+
type: "object",
|
|
475
|
+
properties: {
|
|
476
|
+
readPaths: {
|
|
477
|
+
type: "array",
|
|
478
|
+
items: { type: "string" },
|
|
479
|
+
},
|
|
480
|
+
writePaths: {
|
|
481
|
+
type: "array",
|
|
482
|
+
items: { type: "string" },
|
|
483
|
+
},
|
|
484
|
+
domains: {
|
|
485
|
+
type: "array",
|
|
486
|
+
items: { type: "string" },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
additionalProperties: false,
|
|
490
|
+
description: "Describe the exact additional action scope for approval review.",
|
|
491
|
+
},
|
|
492
|
+
justification: {
|
|
493
|
+
type: "string",
|
|
494
|
+
description: "Explain the boundary crossing for review.",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
: definition.parameters;
|
|
499
|
+
return {
|
|
500
|
+
...definition,
|
|
501
|
+
parameters,
|
|
502
|
+
execute: async (toolCallId, params, signal, onUpdate, context) => {
|
|
290
503
|
this.assertReady();
|
|
291
504
|
const cwd = await canonicalPath(context.cwd, context.cwd);
|
|
292
|
-
if (tool ===
|
|
505
|
+
if (tool === "bash") {
|
|
293
506
|
const input = params;
|
|
294
|
-
const requested = JSON.parse(canonicalJson(Object.fromEntries([
|
|
295
|
-
|
|
507
|
+
const requested = JSON.parse(canonicalJson(Object.fromEntries([
|
|
508
|
+
"sandbox_permissions",
|
|
509
|
+
"additional_permissions",
|
|
510
|
+
"justification",
|
|
511
|
+
]
|
|
512
|
+
.filter((key) => input[key] !== undefined)
|
|
513
|
+
.map((key) => [key, input[key]]))));
|
|
514
|
+
const delegate = createBashToolDefinition(cwd, {
|
|
515
|
+
...toolOptions,
|
|
516
|
+
operations: {
|
|
517
|
+
exec: async (command, finalCwd, options) => {
|
|
296
518
|
const env = options.env;
|
|
297
|
-
const shellPath = toolOptions
|
|
298
|
-
|
|
519
|
+
const shellPath = toolOptions
|
|
520
|
+
?.shellPath ?? "/bin/bash";
|
|
521
|
+
const args = {
|
|
522
|
+
command,
|
|
523
|
+
shellPath,
|
|
524
|
+
...(options.timeout !== undefined
|
|
525
|
+
? { timeout: options.timeout }
|
|
526
|
+
: {}),
|
|
527
|
+
environment: reviewEnvironment(env),
|
|
528
|
+
redactedEnvironmentVariables: redactedEnvironmentNames(env),
|
|
529
|
+
...requested,
|
|
530
|
+
};
|
|
299
531
|
const resolvedCwd = await canonicalPath(finalCwd, finalCwd);
|
|
300
|
-
const action = createAction({
|
|
301
|
-
|
|
302
|
-
|
|
532
|
+
const action = createAction({
|
|
533
|
+
toolCallId,
|
|
534
|
+
tool,
|
|
535
|
+
args: JSON.parse(canonicalJson(args)),
|
|
536
|
+
cwd: resolvedCwd,
|
|
537
|
+
source: this.sources.get(toolCallId) ??
|
|
538
|
+
"model",
|
|
539
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
540
|
+
policyRevision: this.policy.revision,
|
|
541
|
+
}, this.policy.profile);
|
|
542
|
+
return (await this.admitAndExecute(action, {
|
|
543
|
+
kind: "shell",
|
|
544
|
+
command,
|
|
545
|
+
shellPath,
|
|
546
|
+
cwd: resolvedCwd,
|
|
547
|
+
}, { ...context, cwd: finalCwd }, {
|
|
548
|
+
signal: options.signal,
|
|
549
|
+
timeoutSeconds: options.timeout,
|
|
550
|
+
env,
|
|
551
|
+
onData: options.onData,
|
|
552
|
+
}, "", JSON.parse(canonicalJson(params))));
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
});
|
|
303
556
|
return delegate.execute(toolCallId, params, signal, onUpdate, context);
|
|
304
557
|
}
|
|
305
558
|
const boundParameters = JSON.parse(canonicalJson(params));
|
|
306
559
|
const args = { ...boundParameters };
|
|
307
|
-
const target = await resolveToolPath(typeof args.path ===
|
|
560
|
+
const target = await resolveToolPath(typeof args.path === "string" ? args.path : ".", cwd, tool === "read");
|
|
308
561
|
args.path = pathToFileURL(target).href;
|
|
309
|
-
const action = createAction({
|
|
310
|
-
|
|
562
|
+
const action = createAction({
|
|
563
|
+
toolCallId,
|
|
564
|
+
tool,
|
|
565
|
+
args,
|
|
566
|
+
cwd,
|
|
567
|
+
source: this.sources.get(toolCallId) ?? "model",
|
|
568
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
569
|
+
policyRevision: this.policy.revision,
|
|
570
|
+
}, this.policy.profile);
|
|
571
|
+
const options = tool === "read"
|
|
572
|
+
? {
|
|
573
|
+
...toolOptions,
|
|
574
|
+
...(context.model?.inputLimits?.images?.resize
|
|
575
|
+
? {
|
|
576
|
+
resizeOptions: context.model.inputLimits.images
|
|
577
|
+
.resize,
|
|
578
|
+
}
|
|
579
|
+
: {}),
|
|
580
|
+
}
|
|
581
|
+
: undefined;
|
|
311
582
|
const delegate = definition;
|
|
312
|
-
const execute = () => this.admitAndExecute(action, {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
583
|
+
const execute = () => this.admitAndExecute(action, {
|
|
584
|
+
kind: "tool",
|
|
585
|
+
tool,
|
|
586
|
+
args: action.args,
|
|
587
|
+
cwd,
|
|
588
|
+
toolCallId,
|
|
589
|
+
...(options ? { options } : {}),
|
|
590
|
+
}, context, {
|
|
591
|
+
signal,
|
|
592
|
+
onUpdate: onUpdate
|
|
593
|
+
? (result) => onUpdate(result)
|
|
594
|
+
: undefined,
|
|
595
|
+
delegate: async (admittedSignal) => (await delegate.execute(toolCallId, boundParameters, admittedSignal, onUpdate, context)),
|
|
596
|
+
}, "", JSON.parse(canonicalJson(params)));
|
|
597
|
+
const result = await (tool === "write" || tool === "edit"
|
|
598
|
+
? withApprovalMutationQueue(await canonicalPath(target, cwd), execute)
|
|
599
|
+
: execute());
|
|
317
600
|
return result;
|
|
318
|
-
}
|
|
601
|
+
},
|
|
602
|
+
};
|
|
319
603
|
}
|
|
320
604
|
userBashOperations(context, trustedCommand) {
|
|
321
|
-
const operations = {
|
|
605
|
+
const operations = {
|
|
606
|
+
exec: async (command, cwd, options) => {
|
|
322
607
|
const resolvedCwd = await canonicalPath(cwd, cwd);
|
|
323
608
|
const env = options.env;
|
|
324
|
-
const shellPath = this.options.shellPath ??
|
|
325
|
-
const action = createAction({
|
|
326
|
-
|
|
327
|
-
|
|
609
|
+
const shellPath = this.options.shellPath ?? "/bin/bash";
|
|
610
|
+
const action = createAction({
|
|
611
|
+
toolCallId: randomUUID(),
|
|
612
|
+
tool: "bash",
|
|
613
|
+
source: "user-bash",
|
|
614
|
+
args: JSON.parse(canonicalJson({
|
|
615
|
+
command,
|
|
616
|
+
shellPath,
|
|
617
|
+
environment: reviewEnvironment(env),
|
|
618
|
+
redactedEnvironmentVariables: redactedEnvironmentNames(env),
|
|
619
|
+
...(options.timeout !== undefined
|
|
620
|
+
? { timeout: options.timeout }
|
|
621
|
+
: {}),
|
|
622
|
+
})),
|
|
623
|
+
cwd: resolvedCwd,
|
|
624
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
625
|
+
policyRevision: this.policy.revision,
|
|
626
|
+
}, this.policy.profile);
|
|
627
|
+
return (await this.admitAndExecute(action, { kind: "shell", command, shellPath, cwd: resolvedCwd }, context, {
|
|
628
|
+
signal: options.signal,
|
|
629
|
+
timeoutSeconds: options.timeout,
|
|
630
|
+
env: options.env,
|
|
631
|
+
onData: options.onData,
|
|
632
|
+
}, trustedCommand === undefined
|
|
633
|
+
? ""
|
|
634
|
+
: `The user directly requested this shell command: ${trustedCommand}`, { command: trustedCommand ?? command }));
|
|
635
|
+
},
|
|
636
|
+
};
|
|
328
637
|
this.ownedOperations.add(operations);
|
|
329
638
|
return operations;
|
|
330
639
|
}
|
|
331
|
-
isGuardedOperations(operations) {
|
|
640
|
+
isGuardedOperations(operations) {
|
|
641
|
+
return this.ownedOperations.has(operations);
|
|
642
|
+
}
|
|
332
643
|
}
|
|
333
644
|
//# sourceMappingURL=controller.js.map
|