specpi 0.10.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 +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { clearAnalysisCache, decideCommand, decidePath } from "./core.mjs";
|
|
5
|
+
import { boundedReason } from "./redact.mjs";
|
|
6
|
+
import { POLICY_VERSION } from "./rules.mjs";
|
|
7
|
+
|
|
8
|
+
type Mode = "guard" | "strict" | "off" | "locked";
|
|
9
|
+
type State = {
|
|
10
|
+
mode: Mode;
|
|
11
|
+
baseMode: "guard" | "strict" | "off";
|
|
12
|
+
generation: number;
|
|
13
|
+
ready: boolean;
|
|
14
|
+
startupFailed: boolean;
|
|
15
|
+
blocks: number;
|
|
16
|
+
approvals: number;
|
|
17
|
+
sessionApprovals: Set<string>;
|
|
18
|
+
categories: Record<string, number>;
|
|
19
|
+
rules: Record<string, number>;
|
|
20
|
+
criticalRule?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function validRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toolFingerprint(name: string, input: unknown, cwd: string, mode: Mode): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const serialized = JSON.stringify({ name, input, cwd: path.resolve(cwd), mode, policyVersion: POLICY_VERSION });
|
|
30
|
+
if (typeof serialized !== "string") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return crypto.createHash("sha256").update(serialized).digest("hex");
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rememberApproval(state: State, fingerprint: string): void {
|
|
41
|
+
if (state.sessionApprovals.has(fingerprint)) {
|
|
42
|
+
state.sessionApprovals.delete(fingerprint);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
while (state.sessionApprovals.size >= 128) {
|
|
46
|
+
state.sessionApprovals.delete(state.sessionApprovals.values().next().value!);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
state.sessionApprovals.add(fingerprint);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validCommandInput(input: unknown): input is { command: string; timeout?: number } {
|
|
53
|
+
if (!validRecord(input) || typeof input.command !== "string") {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
input.timeout === undefined ||
|
|
59
|
+
(typeof input.timeout === "number" && Number.isFinite(input.timeout) && input.timeout > 0)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validPathInput(input: unknown, toolName: string): input is Record<string, any> {
|
|
64
|
+
if (!validRecord(input) || typeof input.path !== "string" || !input.path) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (toolName === "read") {
|
|
69
|
+
return (
|
|
70
|
+
(input.offset === undefined || (Number.isInteger(input.offset) && input.offset >= 1)) &&
|
|
71
|
+
(input.limit === undefined || (Number.isInteger(input.limit) && input.limit >= 1))
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (toolName === "write") {
|
|
76
|
+
return typeof input.content === "string";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
Array.isArray(input.edits) &&
|
|
81
|
+
input.edits.length > 0 &&
|
|
82
|
+
input.edits.every(
|
|
83
|
+
(edit: any) => validRecord(edit) && typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function withTimeout<T>(promise: Promise<T>, fallback: T, milliseconds = 3000): Promise<T> {
|
|
89
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
try {
|
|
91
|
+
return await Promise.race([
|
|
92
|
+
promise.catch(() => fallback),
|
|
93
|
+
new Promise<T>((resolve) => {
|
|
94
|
+
timer = setTimeout(() => resolve(fallback), milliseconds);
|
|
95
|
+
}),
|
|
96
|
+
]);
|
|
97
|
+
} finally {
|
|
98
|
+
if (timer) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function startupChoice(ctx: ExtensionContext, milliseconds = 3000): Promise<string | undefined> {
|
|
105
|
+
return withTimeout(
|
|
106
|
+
ctx.ui.select("SpecPi command guard", ["Guard (Recommended)", "Strict", "Off for this session"]),
|
|
107
|
+
undefined,
|
|
108
|
+
milliseconds,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function updateStatus(ctx: ExtensionContext, state: State): void {
|
|
113
|
+
const label =
|
|
114
|
+
state.mode === "off"
|
|
115
|
+
? "Guard Off"
|
|
116
|
+
: state.mode === "locked"
|
|
117
|
+
? "🛡 Locked"
|
|
118
|
+
: `🛡 ${state.mode[0].toUpperCase()}${state.mode.slice(1)}`;
|
|
119
|
+
try {
|
|
120
|
+
ctx.ui.setStatus("specpi-command-guard", label);
|
|
121
|
+
} catch {
|
|
122
|
+
/* Status is optional in older hosts. */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function recordDecision(state: State, decision: any): void {
|
|
127
|
+
const category = String(decision?.category || "unknown").slice(0, 48);
|
|
128
|
+
state.categories[category] = (state.categories[category] || 0) + 1;
|
|
129
|
+
for (const id of Array.isArray(decision?.ruleIds) ? decision.ruleIds.slice(0, 32) : []) {
|
|
130
|
+
const key = String(id).slice(0, 96);
|
|
131
|
+
state.rules[key] = (state.rules[key] || 0) + 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decisionPrompt(decision: any, cwd: string, affected: string): string {
|
|
136
|
+
const fields = `Severity: ${decision.severity}; category: ${decision.category}; cwd: ${boundedReason(cwd, 180)}; affected: ${boundedReason(affected || "not resolved", 320)}; reason: ${boundedReason(decision.reason, 320)}; safer: ${boundedReason(decision.saferAlternative || "review and narrow the operation", 220)}`;
|
|
137
|
+
|
|
138
|
+
return boundedReason(fields, 1200);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function deny(state: State, reason: string, critical = false): { block: true; reason: string } {
|
|
142
|
+
state.blocks += 1;
|
|
143
|
+
if (critical) {
|
|
144
|
+
state.mode = "locked";
|
|
145
|
+
state.generation += 1;
|
|
146
|
+
state.sessionApprovals.clear();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { block: true, reason: boundedReason(reason) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default function registerCommandGuard(
|
|
153
|
+
pi: ExtensionAPI,
|
|
154
|
+
dependencies: {
|
|
155
|
+
promptTimeoutMs?: number;
|
|
156
|
+
startupTimeoutMs?: number;
|
|
157
|
+
approvalTimeoutMs?: number;
|
|
158
|
+
} = {},
|
|
159
|
+
): void {
|
|
160
|
+
// Startup only picks a default and falls back to the recommended Guard mode, so it stays short.
|
|
161
|
+
// An approval waits on a person reading severity, category, cwd, affected paths, reason, and alternative;
|
|
162
|
+
// withTimeout cannot cancel the underlying prompt, so a short bound would deny work mid-decision and leave
|
|
163
|
+
// a live selector on screen. Both directions still fail closed, just on a human timescale.
|
|
164
|
+
const startupTimeoutMs = dependencies.startupTimeoutMs ?? dependencies.promptTimeoutMs ?? 30_000;
|
|
165
|
+
const approvalTimeoutMs = dependencies.approvalTimeoutMs ?? dependencies.promptTimeoutMs ?? 600_000;
|
|
166
|
+
const state: State = {
|
|
167
|
+
mode: "guard",
|
|
168
|
+
baseMode: "guard",
|
|
169
|
+
generation: 0,
|
|
170
|
+
ready: false,
|
|
171
|
+
startupFailed: true,
|
|
172
|
+
blocks: 0,
|
|
173
|
+
approvals: 0,
|
|
174
|
+
sessionApprovals: new Set(),
|
|
175
|
+
categories: {},
|
|
176
|
+
rules: {},
|
|
177
|
+
};
|
|
178
|
+
const reset = () => {
|
|
179
|
+
clearAnalysisCache();
|
|
180
|
+
state.mode = "guard";
|
|
181
|
+
state.baseMode = "guard";
|
|
182
|
+
state.generation += 1;
|
|
183
|
+
state.ready = false;
|
|
184
|
+
state.startupFailed = true;
|
|
185
|
+
state.blocks = 0;
|
|
186
|
+
state.approvals = 0;
|
|
187
|
+
state.sessionApprovals.clear();
|
|
188
|
+
state.categories = {};
|
|
189
|
+
state.rules = {};
|
|
190
|
+
state.criticalRule = undefined;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
194
|
+
reset();
|
|
195
|
+
try {
|
|
196
|
+
const choice = ctx.hasUI ? await startupChoice(ctx, startupTimeoutMs) : undefined;
|
|
197
|
+
if (choice === "Strict") {
|
|
198
|
+
state.mode = "strict";
|
|
199
|
+
} else if (choice === "Off for this session") {
|
|
200
|
+
const confirmed = ctx.hasUI
|
|
201
|
+
? await withTimeout(
|
|
202
|
+
ctx.ui.confirm(
|
|
203
|
+
"Turn command guard off for this session?",
|
|
204
|
+
"This removes command-guard defense in depth until this session ends. It is not persisted.",
|
|
205
|
+
),
|
|
206
|
+
false,
|
|
207
|
+
startupTimeoutMs,
|
|
208
|
+
)
|
|
209
|
+
: false;
|
|
210
|
+
state.mode = confirmed ? "off" : "guard";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
state.baseMode = state.mode;
|
|
214
|
+
state.ready = true;
|
|
215
|
+
state.startupFailed = false;
|
|
216
|
+
updateStatus(ctx, state);
|
|
217
|
+
ctx.ui.notify(
|
|
218
|
+
state.mode === "off"
|
|
219
|
+
? "Command guard is off for this session; this is not a sandbox."
|
|
220
|
+
: `Command guard active in ${state.mode} mode.`,
|
|
221
|
+
state.mode === "off" ? "warning" : "info",
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
state.startupFailed = true;
|
|
225
|
+
state.ready = false;
|
|
226
|
+
try {
|
|
227
|
+
ctx.ui.notify("Command guard initialization failed; protected tool calls will be denied.", "error");
|
|
228
|
+
} catch {
|
|
229
|
+
/* fail closed */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
234
|
+
reset();
|
|
235
|
+
try {
|
|
236
|
+
ctx.ui.setStatus("specpi-command-guard", undefined);
|
|
237
|
+
} catch {
|
|
238
|
+
/* optional */
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
pi.registerCommand("guard", {
|
|
243
|
+
description: "Show or change the session command guard",
|
|
244
|
+
getArgumentCompletions: (prefix: string) =>
|
|
245
|
+
["status", "guard", "strict", "off", "unlock", "clear-approvals"]
|
|
246
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
247
|
+
.map((value) => ({ value, label: value })),
|
|
248
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
249
|
+
const action = args.trim().toLowerCase() || "status";
|
|
250
|
+
if (action === "status") {
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
`Mode: ${state.mode}; policy: ${POLICY_VERSION}; lock: ${state.mode === "locked" ? "locked" : "unlocked"}; blocks: ${state.blocks}; approvals: ${state.approvals}; session approvals: ${state.sessionApprovals.size}; categories: ${JSON.stringify(state.categories)}; rules: ${JSON.stringify(state.rules)}`,
|
|
253
|
+
"info",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (state.mode === "locked" && action !== "unlock") {
|
|
260
|
+
ctx.ui.notify(
|
|
261
|
+
"The command guard is locked. Use /guard unlock after reviewing the critical rule.",
|
|
262
|
+
"warning",
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (action === "clear-approvals") {
|
|
269
|
+
state.sessionApprovals.clear();
|
|
270
|
+
state.generation += 1;
|
|
271
|
+
ctx.ui.notify("Session approvals cleared.", "info");
|
|
272
|
+
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (action === "unlock") {
|
|
277
|
+
if (state.mode !== "locked") {
|
|
278
|
+
ctx.ui.notify("The command guard is not locked.", "info");
|
|
279
|
+
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ok =
|
|
284
|
+
ctx.hasUI &&
|
|
285
|
+
(await withTimeout(
|
|
286
|
+
ctx.ui.confirm(
|
|
287
|
+
"Unlock command guard?",
|
|
288
|
+
`The last critical rule was ${state.criticalRule || "unknown"}. Review it before continuing.`,
|
|
289
|
+
),
|
|
290
|
+
false,
|
|
291
|
+
approvalTimeoutMs,
|
|
292
|
+
));
|
|
293
|
+
if (ok) {
|
|
294
|
+
state.mode = state.baseMode;
|
|
295
|
+
state.generation += 1;
|
|
296
|
+
state.sessionApprovals.clear();
|
|
297
|
+
state.criticalRule = undefined;
|
|
298
|
+
updateStatus(ctx, state);
|
|
299
|
+
ctx.ui.notify(`Command guard unlocked in ${state.baseMode} mode.`, "warning");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (action === "off") {
|
|
306
|
+
if (
|
|
307
|
+
!ctx.hasUI ||
|
|
308
|
+
!(await withTimeout(
|
|
309
|
+
ctx.ui.confirm(
|
|
310
|
+
"Turn command guard off?",
|
|
311
|
+
"This applies only to the current top-level session and removes defense in depth.",
|
|
312
|
+
),
|
|
313
|
+
false,
|
|
314
|
+
approvalTimeoutMs,
|
|
315
|
+
))
|
|
316
|
+
) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
state.mode = "off";
|
|
321
|
+
state.baseMode = "off";
|
|
322
|
+
state.generation += 1;
|
|
323
|
+
state.sessionApprovals.clear();
|
|
324
|
+
updateStatus(ctx, state);
|
|
325
|
+
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (action === "strict" || action === "guard") {
|
|
330
|
+
if (
|
|
331
|
+
action === "guard" &&
|
|
332
|
+
state.mode === "strict" &&
|
|
333
|
+
(!ctx.hasUI ||
|
|
334
|
+
!(await withTimeout(
|
|
335
|
+
ctx.ui.confirm(
|
|
336
|
+
"Switch to Guard mode?",
|
|
337
|
+
"This weakens protection for the rest of this session.",
|
|
338
|
+
),
|
|
339
|
+
false,
|
|
340
|
+
approvalTimeoutMs,
|
|
341
|
+
)))
|
|
342
|
+
) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
state.mode = action;
|
|
347
|
+
state.baseMode = action;
|
|
348
|
+
state.generation += 1;
|
|
349
|
+
state.sessionApprovals.clear();
|
|
350
|
+
updateStatus(ctx, state);
|
|
351
|
+
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
ctx.ui.notify("Usage: /guard [status|guard|strict|off|unlock|clear-approvals]", "error");
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
|
|
360
|
+
try {
|
|
361
|
+
const name = typeof event?.toolName === "string" ? event.toolName : "";
|
|
362
|
+
const input = event?.input;
|
|
363
|
+
if (!name) {
|
|
364
|
+
return deny(state, "Malformed tool call.");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!state.ready || state.startupFailed) {
|
|
368
|
+
return deny(state, "Command guard is not initialized; protected tool calls are denied.");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (state.mode === "locked") {
|
|
372
|
+
return deny(state, "The command guard is locked after a critical attempt.");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (name === "bash" || name === "powershell") {
|
|
376
|
+
if (!validCommandInput(input)) {
|
|
377
|
+
return deny(state, `Malformed ${name} input is denied.`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const decision = decideCommand(input.command, {
|
|
381
|
+
mode: state.mode,
|
|
382
|
+
shell: name,
|
|
383
|
+
cwd: ctx.cwd,
|
|
384
|
+
platform: process.platform,
|
|
385
|
+
hasUI: ctx.hasUI,
|
|
386
|
+
});
|
|
387
|
+
recordDecision(state, decision);
|
|
388
|
+
if (decision.action === "deny") {
|
|
389
|
+
const critical = decision.lockSession === true;
|
|
390
|
+
if (critical) {
|
|
391
|
+
state.criticalRule = decision.ruleIds[0];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result = deny(
|
|
395
|
+
state,
|
|
396
|
+
critical
|
|
397
|
+
? `${decision.reason} The session is locked; use /guard status and /guard unlock after review.`
|
|
398
|
+
: decision.reason,
|
|
399
|
+
critical,
|
|
400
|
+
);
|
|
401
|
+
if (critical) {
|
|
402
|
+
updateStatus(ctx, state);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (decision.action === "ask") {
|
|
409
|
+
const approvalFingerprint = toolFingerprint(name, input, ctx.cwd, state.mode);
|
|
410
|
+
if (!approvalFingerprint) {
|
|
411
|
+
return deny(state, "Approval input is malformed or exceeds the safety bound.");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (state.sessionApprovals.has(approvalFingerprint)) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!ctx.hasUI) {
|
|
419
|
+
return deny(state, decision.reason);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const affected = decision.leaves
|
|
423
|
+
.map((leaf: any) => leaf.redactedTarget)
|
|
424
|
+
.filter(Boolean)
|
|
425
|
+
.slice(0, 4)
|
|
426
|
+
.join(", ");
|
|
427
|
+
const approvalGeneration = state.generation;
|
|
428
|
+
const answer = await withTimeout(
|
|
429
|
+
ctx.ui.select(`Command guard approval — ${decisionPrompt(decision, ctx.cwd, affected)}`, [
|
|
430
|
+
"Deny (Recommended)",
|
|
431
|
+
"Allow once",
|
|
432
|
+
"Allow exact call for session",
|
|
433
|
+
"Lock session",
|
|
434
|
+
]),
|
|
435
|
+
undefined,
|
|
436
|
+
approvalTimeoutMs,
|
|
437
|
+
);
|
|
438
|
+
if (
|
|
439
|
+
state.generation !== approvalGeneration ||
|
|
440
|
+
state.mode === "locked" ||
|
|
441
|
+
toolFingerprint(name, input, ctx.cwd, state.mode) !== approvalFingerprint
|
|
442
|
+
) {
|
|
443
|
+
return deny(
|
|
444
|
+
state,
|
|
445
|
+
"Command-guard state or input changed during approval; execution is denied.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (answer === "Allow once") {
|
|
450
|
+
state.approvals += 1;
|
|
451
|
+
state.generation += 1;
|
|
452
|
+
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (answer === "Allow exact call for session") {
|
|
457
|
+
state.approvals += 1;
|
|
458
|
+
rememberApproval(state, approvalFingerprint);
|
|
459
|
+
state.generation += 1;
|
|
460
|
+
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (answer === "Lock session") {
|
|
465
|
+
state.mode = "locked";
|
|
466
|
+
state.generation += 1;
|
|
467
|
+
state.sessionApprovals.clear();
|
|
468
|
+
updateStatus(ctx, state);
|
|
469
|
+
|
|
470
|
+
return deny(state, "The session was locked by command-guard approval.");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return deny(state, decision.reason);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (name === "write" || name === "edit" || name === "read") {
|
|
480
|
+
if (!validPathInput(input, name)) {
|
|
481
|
+
return deny(state, `Malformed ${name} input is denied.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const decision = decidePath(input.path, name, {
|
|
485
|
+
mode: state.mode,
|
|
486
|
+
cwd: ctx.cwd,
|
|
487
|
+
platform: process.platform,
|
|
488
|
+
hasUI: ctx.hasUI,
|
|
489
|
+
});
|
|
490
|
+
recordDecision(state, decision);
|
|
491
|
+
// Refusing a read is enough on its own: nothing was changed, so latching the lock would strand the whole
|
|
492
|
+
// session — every later command, including read-only ones — over one blocked file.
|
|
493
|
+
if (decision.action === "deny") {
|
|
494
|
+
const critical = decision.lockSession === true && name !== "read";
|
|
495
|
+
if (critical) {
|
|
496
|
+
state.criticalRule = decision.ruleIds[0];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const result = deny(
|
|
500
|
+
state,
|
|
501
|
+
critical
|
|
502
|
+
? `${decision.reason} The session is locked; use /guard status and /guard unlock after review.`
|
|
503
|
+
: decision.reason,
|
|
504
|
+
critical,
|
|
505
|
+
);
|
|
506
|
+
if (critical) {
|
|
507
|
+
updateStatus(ctx, state);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (decision.action === "ask") {
|
|
514
|
+
const approvalFingerprint = toolFingerprint(name, input, ctx.cwd, state.mode);
|
|
515
|
+
if (!approvalFingerprint) {
|
|
516
|
+
return deny(state, "Approval input is malformed or exceeds the safety bound.");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (state.sessionApprovals.has(approvalFingerprint)) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (!ctx.hasUI) {
|
|
524
|
+
return deny(state, decision.reason);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const approvalGeneration = state.generation;
|
|
528
|
+
const answer = await withTimeout(
|
|
529
|
+
ctx.ui.select(
|
|
530
|
+
`Path mutation approval — ${decisionPrompt(decision, ctx.cwd, boundedReason(input.path, 180))}`,
|
|
531
|
+
["Deny (Recommended)", "Allow once", "Allow exact call for session", "Lock session"],
|
|
532
|
+
),
|
|
533
|
+
undefined,
|
|
534
|
+
approvalTimeoutMs,
|
|
535
|
+
);
|
|
536
|
+
if (
|
|
537
|
+
state.generation !== approvalGeneration ||
|
|
538
|
+
state.mode === "locked" ||
|
|
539
|
+
toolFingerprint(name, input, ctx.cwd, state.mode) !== approvalFingerprint
|
|
540
|
+
) {
|
|
541
|
+
return deny(state, "Command-guard state or input changed during approval; mutation is denied.");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (answer === "Allow once") {
|
|
545
|
+
state.approvals += 1;
|
|
546
|
+
state.generation += 1;
|
|
547
|
+
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (answer === "Allow exact call for session") {
|
|
552
|
+
state.approvals += 1;
|
|
553
|
+
rememberApproval(state, approvalFingerprint);
|
|
554
|
+
state.generation += 1;
|
|
555
|
+
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (answer === "Lock session") {
|
|
560
|
+
state.mode = "locked";
|
|
561
|
+
state.generation += 1;
|
|
562
|
+
state.sessionApprovals.clear();
|
|
563
|
+
updateStatus(ctx, state);
|
|
564
|
+
|
|
565
|
+
return deny(state, "The session was locked by command-guard approval.");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return deny(state, decision.reason);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (state.mode === "strict") {
|
|
575
|
+
recordDecision(state, { category: "unknown", ruleIds: ["tool.unknown-capability"] });
|
|
576
|
+
const approvalFingerprint = toolFingerprint(name, input, ctx.cwd, state.mode);
|
|
577
|
+
if (!approvalFingerprint) {
|
|
578
|
+
return deny(state, "Unknown-tool approval input is malformed or exceeds the safety bound.");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (state.sessionApprovals.has(approvalFingerprint)) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!ctx.hasUI) {
|
|
586
|
+
return deny(state, "Unknown tools requiring policy review are denied without approval UI.");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const approvalGeneration = state.generation;
|
|
590
|
+
const answer = await withTimeout(
|
|
591
|
+
ctx.ui.select(
|
|
592
|
+
`Unknown tool approval — name: ${boundedReason(name, 96)}; mode: ${state.mode}; capability is not in the reviewed command-guard catalog.`,
|
|
593
|
+
["Deny (Recommended)", "Allow once", "Allow exact call for session", "Lock session"],
|
|
594
|
+
),
|
|
595
|
+
undefined,
|
|
596
|
+
approvalTimeoutMs,
|
|
597
|
+
);
|
|
598
|
+
if (
|
|
599
|
+
state.generation !== approvalGeneration ||
|
|
600
|
+
state.mode === "locked" ||
|
|
601
|
+
toolFingerprint(name, input, ctx.cwd, state.mode) !== approvalFingerprint
|
|
602
|
+
) {
|
|
603
|
+
return deny(state, "Command-guard state or input changed during approval; execution is denied.");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (answer === "Allow once") {
|
|
607
|
+
state.approvals += 1;
|
|
608
|
+
state.generation += 1;
|
|
609
|
+
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (answer === "Allow exact call for session") {
|
|
614
|
+
state.approvals += 1;
|
|
615
|
+
rememberApproval(state, approvalFingerprint);
|
|
616
|
+
state.generation += 1;
|
|
617
|
+
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (answer === "Lock session") {
|
|
622
|
+
state.mode = "locked";
|
|
623
|
+
state.generation += 1;
|
|
624
|
+
state.sessionApprovals.clear();
|
|
625
|
+
updateStatus(ctx, state);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return deny(state, "Unknown tool was not approved.");
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
return deny(state, "Command-guard policy or prompting failed; execution is denied.");
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const COMMAND_GUARD_FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
2
|
+
|
|
3
|
+
export const COMMAND_GUARD_MANAGED_FILES = Object.freeze([
|
|
4
|
+
"index.ts",
|
|
5
|
+
"core.mjs",
|
|
6
|
+
"rules.mjs",
|
|
7
|
+
"bash.mjs",
|
|
8
|
+
"powershell.mjs",
|
|
9
|
+
"powershell-parser.ps1",
|
|
10
|
+
"cmd.mjs",
|
|
11
|
+
"paths.mjs",
|
|
12
|
+
"redact.mjs",
|
|
13
|
+
"smoke.mjs",
|
|
14
|
+
"managed-files.mjs",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
new Set(COMMAND_GUARD_MANAGED_FILES).size !== COMMAND_GUARD_MANAGED_FILES.length ||
|
|
19
|
+
COMMAND_GUARD_MANAGED_FILES.some((name) => !COMMAND_GUARD_FILE_PATTERN.test(name))
|
|
20
|
+
) {
|
|
21
|
+
throw new Error("The command-guard managed-file inventory is malformed.");
|
|
22
|
+
}
|