pum-agent 0.2.0-beta.2 → 0.2.0-beta.4
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/package.json +1 -1
- package/src/animation.tsx +8 -41
- package/src/app.tsx +33 -7
- package/src/check-mode.ts +73 -17
- package/src/check-mutation.ts +19 -8
- package/src/check-paths.ts +128 -0
- package/src/check-policy.ts +103 -15
- package/src/commands.ts +4 -0
- package/src/help-popup.tsx +1 -0
- package/src/main.tsx +6 -2
- package/src/settings-popup.tsx +2 -0
- package/src/settings.ts +38 -0
- package/src/subagents/manager.ts +3 -3
- package/src/transcript.tsx +43 -30
package/package.json
CHANGED
package/src/animation.tsx
CHANGED
|
@@ -313,9 +313,9 @@ export function useBlinkingText(opts: {
|
|
|
313
313
|
return ref;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
/**
|
|
317
|
-
export function markdownCaretContent(text: string
|
|
318
|
-
return text +
|
|
316
|
+
/** Append a stable caret without scheduling Markdown-only source changes. */
|
|
317
|
+
export function markdownCaretContent(text: string): string {
|
|
318
|
+
return text + CARET;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
export type MarkdownCaretBinding = {
|
|
@@ -323,52 +323,19 @@ export type MarkdownCaretBinding = {
|
|
|
323
323
|
content: string;
|
|
324
324
|
};
|
|
325
325
|
|
|
326
|
-
/** Keep a
|
|
326
|
+
/** Keep a stable caret at the end of incrementally rendered Markdown. */
|
|
327
327
|
export function useMarkdownCaret(
|
|
328
328
|
text: string,
|
|
329
329
|
active: boolean,
|
|
330
330
|
): MarkdownCaretBinding {
|
|
331
331
|
const ref = useRef<MarkdownRenderable>(null);
|
|
332
|
-
const { subscribe, enabled } = useClock();
|
|
333
|
-
const latest = useRef(text);
|
|
334
|
-
const caretVisible = useRef(true);
|
|
335
|
-
latest.current = text;
|
|
336
|
-
|
|
337
|
-
const paint = useCallback(() => {
|
|
338
|
-
if (ref.current) {
|
|
339
|
-
ref.current.content = markdownCaretContent(latest.current, caretVisible.current);
|
|
340
|
-
}
|
|
341
|
-
}, []);
|
|
342
|
-
|
|
343
|
-
useEffect(() => {
|
|
344
|
-
if (!active) return;
|
|
345
|
-
if (!enabled) {
|
|
346
|
-
caretVisible.current = true;
|
|
347
|
-
paint();
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
let lastVisible = caretVisible.current;
|
|
352
|
-
const stop = subscribe((elapsedMs) => {
|
|
353
|
-
const nextVisible = elapsedMs % CARET_PERIOD_MS < CARET_PERIOD_MS * 0.6;
|
|
354
|
-
if (nextVisible === lastVisible) return;
|
|
355
|
-
lastVisible = nextVisible;
|
|
356
|
-
caretVisible.current = nextVisible;
|
|
357
|
-
paint();
|
|
358
|
-
});
|
|
359
|
-
paint();
|
|
360
|
-
return stop;
|
|
361
|
-
}, [active, enabled, paint, subscribe]);
|
|
362
|
-
|
|
363
|
-
useEffect(() => {
|
|
364
|
-
if (active) paint();
|
|
365
|
-
}, [text, active, paint]);
|
|
366
332
|
|
|
367
|
-
//
|
|
368
|
-
//
|
|
333
|
+
// A blink would assign MarkdownRenderable.content twice per period. Each
|
|
334
|
+
// assignment starts another incremental parse and asynchronous highlight.
|
|
335
|
+
// Slow terminals can draw the source markers between those highlight passes.
|
|
369
336
|
return {
|
|
370
337
|
ref,
|
|
371
|
-
content: active ? markdownCaretContent(text
|
|
338
|
+
content: active ? markdownCaretContent(text) : text,
|
|
372
339
|
};
|
|
373
340
|
}
|
|
374
341
|
|
package/src/app.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./settings-popup";
|
|
20
20
|
import {
|
|
21
21
|
CHECK_MODE_PROFILES,
|
|
22
|
+
checkPathsForProject,
|
|
22
23
|
MAX_ACTIVE_SUBAGENTS,
|
|
23
24
|
MIN_ACTIVE_SUBAGENTS,
|
|
24
25
|
saveSettings,
|
|
@@ -72,6 +73,7 @@ import {
|
|
|
72
73
|
setExplanationStrength,
|
|
73
74
|
} from "./explanation-strength";
|
|
74
75
|
import { setCheckModeConfig } from "./check-mode";
|
|
76
|
+
import { applyCheckPathCommand, parseCheckPathCommand } from "./check-paths";
|
|
75
77
|
import {
|
|
76
78
|
captureClipboardImage,
|
|
77
79
|
cleanupPendingImages,
|
|
@@ -908,13 +910,13 @@ export function App({
|
|
|
908
910
|
break;
|
|
909
911
|
case "tool_execution_end":
|
|
910
912
|
patchTool(event.toolCallId, {
|
|
911
|
-
state: isRejectedToolResult(event.result)
|
|
913
|
+
state: isRejectedToolResult(event.result, event.toolCallId)
|
|
912
914
|
? "rejected"
|
|
913
915
|
: event.isError
|
|
914
916
|
? "error"
|
|
915
917
|
: "ok",
|
|
916
|
-
detail: isRejectedToolResult(event.result)
|
|
917
|
-
? rejectedToolReason(event.result)
|
|
918
|
+
detail: isRejectedToolResult(event.result, event.toolCallId)
|
|
919
|
+
? rejectedToolReason(event.result, event.toolCallId)
|
|
918
920
|
: event.toolName === "edit" || event.toolName === "apply_patch"
|
|
919
921
|
? editCounts(event.result)
|
|
920
922
|
: event.toolName === "questionnaire"
|
|
@@ -1027,8 +1029,12 @@ export function App({
|
|
|
1027
1029
|
if (patch.explanationStrength !== undefined) {
|
|
1028
1030
|
setExplanationStrength(patch.explanationStrength);
|
|
1029
1031
|
}
|
|
1030
|
-
if (patch.checkMode !== undefined || patch.checkModel !== undefined) {
|
|
1031
|
-
setCheckModeConfig({
|
|
1032
|
+
if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
|
|
1033
|
+
setCheckModeConfig({
|
|
1034
|
+
profile: next.checkMode,
|
|
1035
|
+
model: next.checkModel,
|
|
1036
|
+
additionalPaths: checkPathsForProject(next, cwd),
|
|
1037
|
+
});
|
|
1032
1038
|
}
|
|
1033
1039
|
if (patch.showThinking !== undefined) showThinkingRef.current = patch.showThinking;
|
|
1034
1040
|
if (patch.maxActiveSubagents !== undefined) {
|
|
@@ -1248,9 +1254,10 @@ export function App({
|
|
|
1248
1254
|
const clear = /^\/(?:clear|new)$/.test(trimmed);
|
|
1249
1255
|
const historyCommand = trimmed === "/history";
|
|
1250
1256
|
const loginCommand = trimmed === "/login";
|
|
1257
|
+
const checkPathCommand = /^\/check-path(?:\s|$)/.test(trimmed);
|
|
1251
1258
|
const triggersCommand = trimmed === "/triggers";
|
|
1252
1259
|
const worktreeCommand = /^\/worktree(?:\s+([a-zA-Z0-9_-]+))?$/.exec(trimmed);
|
|
1253
|
-
if (!compress && !clear && !historyCommand && !loginCommand && !triggersCommand && !worktreeCommand) return false;
|
|
1260
|
+
if (!compress && !clear && !historyCommand && !loginCommand && !checkPathCommand && !triggersCommand && !worktreeCommand) return false;
|
|
1254
1261
|
editingStashIndex.current = null;
|
|
1255
1262
|
|
|
1256
1263
|
if (historyCommand) {
|
|
@@ -1279,7 +1286,20 @@ export function App({
|
|
|
1279
1286
|
}
|
|
1280
1287
|
|
|
1281
1288
|
setWorking(true);
|
|
1282
|
-
if (
|
|
1289
|
+
if (checkPathCommand) {
|
|
1290
|
+
Promise.resolve()
|
|
1291
|
+
.then(() => parseCheckPathCommand(trimmed))
|
|
1292
|
+
.then((command) => {
|
|
1293
|
+
if (!command) throw new Error("invalid /check-path command");
|
|
1294
|
+
return applyCheckPathCommand(settings, cwd, command);
|
|
1295
|
+
})
|
|
1296
|
+
.then((result) => {
|
|
1297
|
+
update({ checkPaths: result.settings.checkPaths });
|
|
1298
|
+
append({ kind: "text", role: "system", text: result.message });
|
|
1299
|
+
})
|
|
1300
|
+
.catch((err) => append({ kind: "text", role: "error", text: String(err) }))
|
|
1301
|
+
.finally(() => setWorking(false));
|
|
1302
|
+
} else if (worktreeCommand) {
|
|
1283
1303
|
runWorktreeCommand({
|
|
1284
1304
|
name: worktreeCommand[1],
|
|
1285
1305
|
manager: subagentManager,
|
|
@@ -1514,6 +1534,11 @@ export function App({
|
|
|
1514
1534
|
settingsPageRef.current = "checkModels";
|
|
1515
1535
|
setPage("checkModels");
|
|
1516
1536
|
} },
|
|
1537
|
+
checkPaths: { enter: () => append({
|
|
1538
|
+
kind: "text",
|
|
1539
|
+
role: "system",
|
|
1540
|
+
text: "use /check-path [list|add <directory>|remove <directory>|clear]",
|
|
1541
|
+
}) },
|
|
1517
1542
|
clearCheckApprovals: { enter: () => {
|
|
1518
1543
|
const removed = checkApprovalStore?.clearProject(cwd) ?? 0;
|
|
1519
1544
|
append({ kind: "text", role: "system", text: removed > 0
|
|
@@ -1551,6 +1576,7 @@ export function App({
|
|
|
1551
1576
|
explanationStrength: `‹ ${settings.explanationStrength} ›`,
|
|
1552
1577
|
checkMode: `‹ ${settings.checkMode} ›`,
|
|
1553
1578
|
checkModel: `${settings.checkModel} ›`,
|
|
1579
|
+
checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
|
|
1554
1580
|
clearCheckApprovals: "clear ›",
|
|
1555
1581
|
thinkingLevel: `‹ ${thinkingLevel} ›`,
|
|
1556
1582
|
showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
|
package/src/check-mode.ts
CHANGED
|
@@ -30,9 +30,32 @@ export const CHECK_MODE_CACHE_LIMIT = 256;
|
|
|
30
30
|
export type CheckModeConfig = {
|
|
31
31
|
profile: CheckModeProfile;
|
|
32
32
|
model: string;
|
|
33
|
+
additionalPaths?: readonly string[];
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
const REJECTED_TOOL_DETAIL = "pumRejected";
|
|
37
|
+
const pendingRejectedTools = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
function toolResultText(result: unknown): string | undefined {
|
|
40
|
+
const content = (result as { content?: unknown } | null)?.content;
|
|
41
|
+
if (!Array.isArray(content)) return undefined;
|
|
42
|
+
const text = content
|
|
43
|
+
.filter((block): block is { type: "text"; text: string } => (
|
|
44
|
+
Boolean(block) && typeof block === "object"
|
|
45
|
+
&& (block as { type?: unknown }).type === "text"
|
|
46
|
+
&& typeof (block as { text?: unknown }).text === "string"
|
|
47
|
+
))
|
|
48
|
+
.map((block) => block.text)
|
|
49
|
+
.join("")
|
|
50
|
+
.trim();
|
|
51
|
+
return text || undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pendingRejectedToolReason(toolCallId: string | undefined, result: unknown): string | undefined {
|
|
55
|
+
if (!toolCallId) return undefined;
|
|
56
|
+
const reason = pendingRejectedTools.get(toolCallId);
|
|
57
|
+
return reason && toolResultText(result) === reason ? reason : undefined;
|
|
58
|
+
}
|
|
36
59
|
|
|
37
60
|
export function rejectedToolDetails(details: unknown, reason?: string): unknown {
|
|
38
61
|
const marker = { [REJECTED_TOOL_DETAIL]: true, ...(reason ? { pumRejectionReason: reason } : {}) };
|
|
@@ -40,29 +63,32 @@ export function rejectedToolDetails(details: unknown, reason?: string): unknown
|
|
|
40
63
|
return marker;
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
export function isRejectedToolResult(result: unknown): boolean {
|
|
66
|
+
export function isRejectedToolResult(result: unknown, toolCallId?: string): boolean {
|
|
44
67
|
const details = (result as { details?: unknown } | null)?.details;
|
|
45
|
-
|
|
46
|
-
&& (details as Record<string, unknown>)[REJECTED_TOOL_DETAIL] === true);
|
|
68
|
+
if (details && typeof details === "object"
|
|
69
|
+
&& (details as Record<string, unknown>)[REJECTED_TOOL_DETAIL] === true) return true;
|
|
70
|
+
return pendingRejectedToolReason(toolCallId, result) !== undefined;
|
|
47
71
|
}
|
|
48
72
|
|
|
49
|
-
export function rejectedToolReason(result: unknown): string | undefined {
|
|
73
|
+
export function rejectedToolReason(result: unknown, toolCallId?: string): string | undefined {
|
|
50
74
|
const details = (result as { details?: unknown } | null)?.details;
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
if (details && typeof details === "object") {
|
|
76
|
+
const reason = (details as Record<string, unknown>).pumRejectionReason;
|
|
77
|
+
if (typeof reason === "string" && reason.trim()) return reason;
|
|
78
|
+
}
|
|
79
|
+
return pendingRejectedToolReason(toolCallId, result);
|
|
54
80
|
}
|
|
55
81
|
|
|
56
|
-
let current: CheckModeConfig = { profile: "off", model: DEFAULT_CHECK_MODEL };
|
|
82
|
+
let current: Required<CheckModeConfig> = { profile: "off", model: DEFAULT_CHECK_MODEL, additionalPaths: [] };
|
|
57
83
|
|
|
58
84
|
export function setCheckModeConfig(config: CheckModeConfig | { enabled: boolean; model: string }): void {
|
|
59
85
|
current = "profile" in config
|
|
60
|
-
? { ...config }
|
|
61
|
-
: { profile: config.enabled ? "strict" : "off", model: config.model };
|
|
86
|
+
? { ...config, additionalPaths: [...(config.additionalPaths ?? [])] }
|
|
87
|
+
: { profile: config.enabled ? "strict" : "off", model: config.model, additionalPaths: [] };
|
|
62
88
|
}
|
|
63
89
|
|
|
64
90
|
export function getCheckModeConfig(): CheckModeConfig {
|
|
65
|
-
return { ...current };
|
|
91
|
+
return { ...current, additionalPaths: [...current.additionalPaths] };
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
function modelRef(model: Model<any>): string {
|
|
@@ -216,6 +242,7 @@ export async function prepareCheck(
|
|
|
216
242
|
cwd: string,
|
|
217
243
|
profile: Exclude<CheckModeProfile, "off">,
|
|
218
244
|
context: UntrustedContext = {},
|
|
245
|
+
additionalPaths: readonly string[] = [],
|
|
219
246
|
): Promise<{ prepared?: PreparedCheck; block?: string; balancedAllow?: string }> {
|
|
220
247
|
let canonicalInput: string;
|
|
221
248
|
try {
|
|
@@ -234,12 +261,13 @@ export async function prepareCheck(
|
|
|
234
261
|
args: input.args,
|
|
235
262
|
cwd: input.cwd,
|
|
236
263
|
projectCwd: cwd,
|
|
264
|
+
allowedPaths: additionalPaths,
|
|
237
265
|
profile,
|
|
238
266
|
});
|
|
239
267
|
} else {
|
|
240
268
|
const command = input && typeof input === "object" ? (input as { command?: unknown }).command : undefined;
|
|
241
269
|
if (typeof command !== "string") return { block: "Bash safety check requires a complete command string or process proposal" };
|
|
242
|
-
policy = analyzeCheckPolicy({ command, cwd, profile });
|
|
270
|
+
policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths });
|
|
243
271
|
}
|
|
244
272
|
bash = policy.analysis;
|
|
245
273
|
if (!bash.complete || bash.truncated || !bash.syntaxBalanced) {
|
|
@@ -248,7 +276,7 @@ export async function prepareCheck(
|
|
|
248
276
|
if (policy.decision === "block") return { block: `Check mode hard block: ${policy.reason}` };
|
|
249
277
|
} else {
|
|
250
278
|
try {
|
|
251
|
-
mutation = await previewMutation(toolName, cwd, input);
|
|
279
|
+
mutation = await previewMutation(toolName, cwd, input, additionalPaths);
|
|
252
280
|
} catch (error) {
|
|
253
281
|
return { block: `Check mode blocked invalid or stale ${toolName} input: ${error instanceof Error ? error.message : String(error)}` };
|
|
254
282
|
}
|
|
@@ -281,6 +309,7 @@ export async function prepareCheck(
|
|
|
281
309
|
version: 2,
|
|
282
310
|
complete: true,
|
|
283
311
|
cwd,
|
|
312
|
+
allowedDirectoryRoots: [cwd, ...additionalPaths],
|
|
284
313
|
tool: toolName,
|
|
285
314
|
input,
|
|
286
315
|
deterministicPolicy: policy ? {
|
|
@@ -330,6 +359,7 @@ export async function prepareCheck(
|
|
|
330
359
|
version: 2,
|
|
331
360
|
complete: true,
|
|
332
361
|
cwd,
|
|
362
|
+
allowedDirectoryRoots: [cwd, ...additionalPaths],
|
|
333
363
|
tool: toolName,
|
|
334
364
|
reviewCoverage: {
|
|
335
365
|
mode: "complete-metadata-digest",
|
|
@@ -526,8 +556,10 @@ async function withHardTimeout<T>(operation: (signal: AbortSignal) => Promise<T>
|
|
|
526
556
|
}
|
|
527
557
|
}
|
|
528
558
|
|
|
529
|
-
function normalizeConfig(config: ToolCheck["config"]): CheckModeConfig {
|
|
530
|
-
return "profile" in config
|
|
559
|
+
function normalizeConfig(config: ToolCheck["config"]): Required<CheckModeConfig> {
|
|
560
|
+
return "profile" in config
|
|
561
|
+
? { ...config, additionalPaths: [...(config.additionalPaths ?? [])] }
|
|
562
|
+
: { profile: config.enabled ? "strict" : "off", model: config.model, additionalPaths: [] };
|
|
531
563
|
}
|
|
532
564
|
|
|
533
565
|
const NPM_BOOLEAN_RELEASE_FLAGS = new Set([
|
|
@@ -619,7 +651,14 @@ export async function evaluateToolCall(runtime: CheckerRuntime, cache: BashSafet
|
|
|
619
651
|
if (config.profile === "off") return { decision: "allow", reason: "Check mode is off", category: "off" };
|
|
620
652
|
if (call.signal?.aborted) return { decision: "block", reason: "Safety check aborted", category: "abort" };
|
|
621
653
|
const profile = config.profile as Exclude<CheckModeProfile, "off">;
|
|
622
|
-
const preparedResult = await prepareCheck(
|
|
654
|
+
const preparedResult = await prepareCheck(
|
|
655
|
+
call.toolName,
|
|
656
|
+
call.input,
|
|
657
|
+
call.cwd,
|
|
658
|
+
profile,
|
|
659
|
+
call.context,
|
|
660
|
+
config.additionalPaths,
|
|
661
|
+
);
|
|
623
662
|
if (!preparedResult.prepared) return { decision: "block", reason: preparedResult.block ?? "Safety preparation failed", category: "hard-block" };
|
|
624
663
|
const prepared = preparedResult.prepared;
|
|
625
664
|
if (profile === "ask" && call.isApproved?.(prepared)) {
|
|
@@ -936,15 +975,32 @@ export function createCheckModeExtension(
|
|
|
936
975
|
|
|
937
976
|
const visibleReason = redactApprovalPreview(evaluation.reason);
|
|
938
977
|
rejected.set(event.toolCallId, visibleReason);
|
|
978
|
+
pendingRejectedTools.set(event.toolCallId, visibleReason);
|
|
939
979
|
return { block: true, reason: visibleReason };
|
|
940
980
|
});
|
|
941
981
|
|
|
942
982
|
pi.on("tool_result", (event) => {
|
|
943
983
|
const reason = rejected.get(event.toolCallId);
|
|
944
984
|
if (!reason) return;
|
|
945
|
-
rejected.delete(event.toolCallId);
|
|
946
985
|
return { details: rejectedToolDetails(event.details, reason) };
|
|
947
986
|
});
|
|
987
|
+
|
|
988
|
+
// pi 0.84 does not call tool_result for calls blocked by beforeToolCall.
|
|
989
|
+
// Mark the finalized message so session replay keeps the rejected state.
|
|
990
|
+
pi.on("message_end", (event) => {
|
|
991
|
+
const message = event.message as any;
|
|
992
|
+
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return;
|
|
993
|
+
const reason = rejected.get(message.toolCallId);
|
|
994
|
+
if (!reason) return;
|
|
995
|
+
rejected.delete(message.toolCallId);
|
|
996
|
+
pendingRejectedTools.delete(message.toolCallId);
|
|
997
|
+
return {
|
|
998
|
+
message: {
|
|
999
|
+
...message,
|
|
1000
|
+
details: rejectedToolDetails(message.details, reason),
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
});
|
|
948
1004
|
},
|
|
949
1005
|
};
|
|
950
1006
|
}
|
package/src/check-mutation.ts
CHANGED
|
@@ -71,14 +71,21 @@ function insideRoot(root: string, path: string): boolean {
|
|
|
71
71
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function validateEditPath(
|
|
74
|
+
async function validateEditPath(
|
|
75
|
+
cwd: string,
|
|
76
|
+
inputPath: string,
|
|
77
|
+
allowedPaths: readonly string[],
|
|
78
|
+
): Promise<{ root: string; absolute: string; display: string; mode: number }> {
|
|
75
79
|
if (!inputPath || inputPath.includes("\0")) throw new Error("Edit path is invalid");
|
|
76
80
|
if (windowsAbsolute(inputPath) && process.platform !== "win32") {
|
|
77
81
|
throw new Error(`Edit path is outside the project: ${inputPath}`);
|
|
78
82
|
}
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
83
|
+
const projectRoot = await realpath(cwd);
|
|
84
|
+
const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
|
|
85
|
+
const absolute = resolve(projectRoot, inputPath);
|
|
86
|
+
const root = roots.sort((first, second) => second.length - first.length)
|
|
87
|
+
.find((candidate) => candidate === absolute || insideRoot(candidate, absolute));
|
|
88
|
+
if (!root) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
|
|
82
89
|
|
|
83
90
|
let component = root;
|
|
84
91
|
const rel = relative(root, absolute);
|
|
@@ -91,7 +98,10 @@ async function validateEditPath(cwd: string, inputPath: string): Promise<{ root:
|
|
|
91
98
|
if (!insideRoot(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
|
|
92
99
|
const metadata = await lstat(absolute);
|
|
93
100
|
if (!metadata.isFile()) throw new Error(`Edit path is not a file: ${inputPath}`);
|
|
94
|
-
|
|
101
|
+
const display = projectRoot === absolute || insideRoot(projectRoot, absolute)
|
|
102
|
+
? relative(projectRoot, absolute).split(sep).join("/")
|
|
103
|
+
: absolute;
|
|
104
|
+
return { root, absolute, display, mode: metadata.mode };
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
function occurrences(content: string, needle: string): number[] {
|
|
@@ -144,7 +154,7 @@ function completeContentMetadata(patch: string): Pick<MutationPreview, "contentC
|
|
|
144
154
|
};
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview> {
|
|
157
|
+
async function previewEdit(cwd: string, input: unknown, allowedPaths: readonly string[]): Promise<MutationPreview> {
|
|
148
158
|
if (!input || typeof input !== "object") throw new Error("Edit input is invalid");
|
|
149
159
|
const value = input as { path?: unknown; edits?: unknown; oldText?: unknown; newText?: unknown };
|
|
150
160
|
if (typeof value.path !== "string") throw new Error("Edit path is invalid");
|
|
@@ -162,7 +172,7 @@ async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview
|
|
|
162
172
|
&& typeof (edit as any).newText === "string",
|
|
163
173
|
)) throw new Error("Edit replacements are invalid");
|
|
164
174
|
|
|
165
|
-
const validated = await validateEditPath(cwd, value.path);
|
|
175
|
+
const validated = await validateEditPath(cwd, value.path, allowedPaths);
|
|
166
176
|
const buffer = await readFile(validated.absolute);
|
|
167
177
|
let content: string;
|
|
168
178
|
try {
|
|
@@ -204,8 +214,9 @@ export async function previewMutation(
|
|
|
204
214
|
toolName: CheckedToolName,
|
|
205
215
|
cwd: string,
|
|
206
216
|
input: unknown,
|
|
217
|
+
allowedPaths: readonly string[] = [],
|
|
207
218
|
): Promise<MutationPreview | undefined> {
|
|
208
|
-
if (toolName === "edit") return previewEdit(cwd, input);
|
|
219
|
+
if (toolName === "edit") return previewEdit(cwd, input, allowedPaths);
|
|
209
220
|
if (toolName !== "apply_patch") return undefined;
|
|
210
221
|
if (!input || typeof input !== "object" || typeof (input as { patch?: unknown }).patch !== "string") {
|
|
211
222
|
throw new Error("Apply patch input is invalid");
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { AGENT_DIR } from "./config";
|
|
5
|
+
import { isCredentialSensitivePath } from "./check-policy";
|
|
6
|
+
import {
|
|
7
|
+
checkPathsForProject,
|
|
8
|
+
MAX_CHECK_PATHS_PER_PROJECT,
|
|
9
|
+
type PumSettings,
|
|
10
|
+
withCheckPathsForProject,
|
|
11
|
+
} from "./settings";
|
|
12
|
+
|
|
13
|
+
export type CheckPathCommand =
|
|
14
|
+
| { action: "list" }
|
|
15
|
+
| { action: "clear" }
|
|
16
|
+
| { action: "add" | "remove"; path: string };
|
|
17
|
+
|
|
18
|
+
function unquote(value: string): string {
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
if (trimmed.length >= 2) {
|
|
21
|
+
const first = trimmed[0];
|
|
22
|
+
if ((first === "\"" || first === "'") && trimmed.at(-1) === first) {
|
|
23
|
+
return trimmed.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseCheckPathCommand(input: string): CheckPathCommand | undefined {
|
|
30
|
+
const match = /^\/check-path(?:\s+(.*))?$/s.exec(input.trim());
|
|
31
|
+
if (!match) return undefined;
|
|
32
|
+
const argumentsText = match[1]?.trim() ?? "";
|
|
33
|
+
if (!argumentsText || argumentsText === "list") return { action: "list" };
|
|
34
|
+
if (argumentsText === "clear") return { action: "clear" };
|
|
35
|
+
const action = /^(add|remove)\s+([\s\S]+)$/.exec(argumentsText);
|
|
36
|
+
if (!action) throw new Error("usage: /check-path [list|add <directory>|remove <directory>|clear]");
|
|
37
|
+
const path = unquote(action[2]!);
|
|
38
|
+
if (!path || path.includes("\0")) throw new Error("Check path is invalid");
|
|
39
|
+
return { action: action[1] as "add" | "remove", path };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function insideOrSame(parent: string, candidate: string): boolean {
|
|
43
|
+
const rel = relative(parent, candidate);
|
|
44
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function canonicalDirectory(input: string, cwd: string): Promise<string> {
|
|
48
|
+
const absolute = resolve(cwd, input);
|
|
49
|
+
const canonical = await realpath(absolute);
|
|
50
|
+
const metadata = await lstat(canonical);
|
|
51
|
+
if (!metadata.isDirectory()) throw new Error(`Check path is not a directory: ${input}`);
|
|
52
|
+
if (dirname(canonical) === canonical) throw new Error("Check path cannot be a filesystem root");
|
|
53
|
+
if (isCredentialSensitivePath(canonical)) {
|
|
54
|
+
throw new Error("Check path cannot be a credential-sensitive directory");
|
|
55
|
+
}
|
|
56
|
+
const [agentDirectory, homeDirectory] = await Promise.all([
|
|
57
|
+
realpath(AGENT_DIR).catch(() => resolve(AGENT_DIR)),
|
|
58
|
+
realpath(homedir()).catch(() => resolve(homedir())),
|
|
59
|
+
]);
|
|
60
|
+
if (insideOrSame(canonical, agentDirectory) || insideOrSame(agentDirectory, canonical)) {
|
|
61
|
+
throw new Error("Check path cannot contain or enter PUM's configuration directory");
|
|
62
|
+
}
|
|
63
|
+
if (insideOrSame(canonical, homeDirectory)) {
|
|
64
|
+
throw new Error("Check path cannot contain the home directory");
|
|
65
|
+
}
|
|
66
|
+
return canonical;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function removalIdentity(input: string, cwd: string): Promise<string> {
|
|
70
|
+
const absolute = resolve(cwd, input);
|
|
71
|
+
return realpath(absolute).catch(() => absolute);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function applyCheckPathCommand(
|
|
75
|
+
settings: PumSettings,
|
|
76
|
+
cwd: string,
|
|
77
|
+
command: CheckPathCommand,
|
|
78
|
+
): Promise<{ settings: PumSettings; paths: string[]; message: string }> {
|
|
79
|
+
const paths = checkPathsForProject(settings, cwd);
|
|
80
|
+
if (command.action === "list") {
|
|
81
|
+
return {
|
|
82
|
+
settings,
|
|
83
|
+
paths,
|
|
84
|
+
message: paths.length > 0
|
|
85
|
+
? `Check mode additional paths:\n${paths.map((path) => `- ${path}`).join("\n")}`
|
|
86
|
+
: "Check mode has no additional paths for this project",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (command.action === "clear") {
|
|
90
|
+
const next = withCheckPathsForProject(settings, cwd, []);
|
|
91
|
+
return {
|
|
92
|
+
settings: next,
|
|
93
|
+
paths: [],
|
|
94
|
+
message: paths.length > 0
|
|
95
|
+
? `cleared ${paths.length} Check mode additional path${paths.length === 1 ? "" : "s"}`
|
|
96
|
+
: "Check mode has no additional paths to clear",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const canonical = command.action === "add"
|
|
101
|
+
? await canonicalDirectory(command.path, cwd)
|
|
102
|
+
: await removalIdentity(command.path, cwd);
|
|
103
|
+
const project = await realpath(cwd);
|
|
104
|
+
if (command.action === "add") {
|
|
105
|
+
if (canonical === project || insideOrSame(project, canonical)) {
|
|
106
|
+
throw new Error("The directory is already inside the project boundary");
|
|
107
|
+
}
|
|
108
|
+
if (paths.includes(canonical)) throw new Error(`Check path is already allowed: ${canonical}`);
|
|
109
|
+
if (paths.length >= MAX_CHECK_PATHS_PER_PROJECT) {
|
|
110
|
+
throw new Error(`Check mode allows at most ${MAX_CHECK_PATHS_PER_PROJECT} additional paths per project`);
|
|
111
|
+
}
|
|
112
|
+
const nextPaths = [...paths, canonical];
|
|
113
|
+
return {
|
|
114
|
+
settings: withCheckPathsForProject(settings, cwd, nextPaths),
|
|
115
|
+
paths: nextPaths,
|
|
116
|
+
message: `allowed Check mode path: ${canonical}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const index = paths.indexOf(canonical);
|
|
121
|
+
if (index < 0) throw new Error(`Check path is not configured: ${canonical}`);
|
|
122
|
+
const nextPaths = paths.filter((_path, candidate) => candidate !== index);
|
|
123
|
+
return {
|
|
124
|
+
settings: withCheckPathsForProject(settings, cwd, nextPaths),
|
|
125
|
+
paths: nextPaths,
|
|
126
|
+
message: `removed Check mode path: ${canonical}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/check-policy.ts
CHANGED
|
@@ -104,6 +104,8 @@ export type CheckPolicyFileSystem = {
|
|
|
104
104
|
export type AnalyzeCheckPolicyOptions = {
|
|
105
105
|
command: string;
|
|
106
106
|
cwd: string;
|
|
107
|
+
/** Canonical directory roots explicitly added for this launch project. */
|
|
108
|
+
allowedPaths?: readonly string[];
|
|
107
109
|
profile?: CheckPolicyProfile;
|
|
108
110
|
limits?: Partial<CheckPolicyLimits>;
|
|
109
111
|
fileSystem?: CheckPolicyFileSystem;
|
|
@@ -125,6 +127,8 @@ export type ProcessCheckProposal = {
|
|
|
125
127
|
|
|
126
128
|
export type AnalyzeExecutablePolicyOptions = Pick<ProcessCheckProposal, "executable" | "args" | "cwd"> & {
|
|
127
129
|
projectCwd?: string;
|
|
130
|
+
/** Canonical directory roots explicitly added for this launch project. */
|
|
131
|
+
allowedPaths?: readonly string[];
|
|
128
132
|
profile?: CheckPolicyProfile;
|
|
129
133
|
limits?: Partial<CheckPolicyLimits>;
|
|
130
134
|
fileSystem?: CheckPolicyFileSystem;
|
|
@@ -160,6 +164,7 @@ const PATH_OPERAND_COMMANDS = new Set([
|
|
|
160
164
|
"readlink", "rm", "rmdir", "unlink", "shred", "mv", "cp", "install", "mkdir", "touch", "truncate",
|
|
161
165
|
"chmod", "chown", "chgrp", "setfacl", "tee", "sed", "perl",
|
|
162
166
|
"node", "bun", "deno", "python", "python3", "ruby", "perl", "php", "java",
|
|
167
|
+
"cd", "chdir", "set-location",
|
|
163
168
|
]);
|
|
164
169
|
const PRIVILEGE_COMMANDS = new Set(["sudo", "doas", "su", "pkexec", "runas"]);
|
|
165
170
|
const PERSISTENCE_COMMANDS = new Set(["crontab", "at", "schtasks", "launchctl"]);
|
|
@@ -517,23 +522,40 @@ function looksLikePath(value: string): boolean {
|
|
|
517
522
|
|| value.includes("/") || value.includes("\\");
|
|
518
523
|
}
|
|
519
524
|
|
|
520
|
-
function normalizedAbsolute(
|
|
525
|
+
function normalizedAbsolute(
|
|
526
|
+
value: string,
|
|
527
|
+
cwd: string,
|
|
528
|
+
projectCwd = cwd,
|
|
529
|
+
allowedPaths: readonly string[] = [],
|
|
530
|
+
): { absolute: string; root: string; inside: boolean; additional: boolean } {
|
|
521
531
|
const flavor = pathFlavor(value, cwd);
|
|
522
|
-
const
|
|
532
|
+
const roots = [projectCwd, ...allowedPaths]
|
|
533
|
+
.map((root) => flavor.resolve(root))
|
|
534
|
+
.sort((first, second) => second.length - first.length);
|
|
523
535
|
const expanded = value === "~" || value.startsWith("~/") || value.startsWith("~\\") ? value : value;
|
|
524
536
|
const absolute = flavor.resolve(cwd, expanded);
|
|
537
|
+
const root = roots.find((candidate) => {
|
|
538
|
+
const relative = flavor.relative(candidate, absolute);
|
|
539
|
+
return relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
|
|
540
|
+
}) ?? flavor.resolve(projectCwd);
|
|
525
541
|
const relative = flavor.relative(root, absolute);
|
|
526
542
|
const inside = relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
|
|
527
|
-
return {
|
|
543
|
+
return {
|
|
544
|
+
absolute,
|
|
545
|
+
root,
|
|
546
|
+
inside,
|
|
547
|
+
additional: flavor.relative(flavor.resolve(projectCwd), root) !== "",
|
|
548
|
+
};
|
|
528
549
|
}
|
|
529
550
|
|
|
530
|
-
function
|
|
551
|
+
function isAllowedRoot(value: string, cwd: string, projectCwd: string, allowedPaths: readonly string[]): boolean {
|
|
531
552
|
const flavor = pathFlavor(value, cwd);
|
|
532
|
-
const
|
|
533
|
-
return
|
|
553
|
+
const absolute = flavor.resolve(cwd, value);
|
|
554
|
+
return [projectCwd, ...allowedPaths]
|
|
555
|
+
.some((root) => flavor.relative(flavor.resolve(root), absolute) === "");
|
|
534
556
|
}
|
|
535
557
|
|
|
536
|
-
function
|
|
558
|
+
export function isCredentialSensitivePath(value: string): boolean {
|
|
537
559
|
const lower = value.toLowerCase().replaceAll("\\", "/");
|
|
538
560
|
const segments = lower.split("/").filter(Boolean);
|
|
539
561
|
if (segments.some((segment) => CREDENTIAL_SEGMENTS.has(segment))) return true;
|
|
@@ -589,6 +611,22 @@ function symlinkEscapes(path: string, root: string, fs: CheckPolicyFileSystem):
|
|
|
589
611
|
}
|
|
590
612
|
}
|
|
591
613
|
|
|
614
|
+
function additionalRootProblem(
|
|
615
|
+
root: string,
|
|
616
|
+
fs: CheckPolicyFileSystem,
|
|
617
|
+
): "missing" | "link" | undefined {
|
|
618
|
+
try {
|
|
619
|
+
if (!fs.exists(root)) return "missing";
|
|
620
|
+
if (fs.isSymbolicLink(root)) return "link";
|
|
621
|
+
const flavor = pathFlavor(root, root);
|
|
622
|
+
return flavor.relative(flavor.resolve(root), flavor.resolve(fs.realpath(root))) === ""
|
|
623
|
+
? undefined
|
|
624
|
+
: "link";
|
|
625
|
+
} catch {
|
|
626
|
+
return "link";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
592
630
|
function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding): void {
|
|
593
631
|
if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
|
|
594
632
|
}
|
|
@@ -599,6 +637,7 @@ function inspectHardBlocks(
|
|
|
599
637
|
fs: CheckPolicyFileSystem,
|
|
600
638
|
depth = 0,
|
|
601
639
|
projectCwd = cwd,
|
|
640
|
+
allowedPaths: readonly string[] = [],
|
|
602
641
|
): CheckPolicyFinding[] {
|
|
603
642
|
const findings: CheckPolicyFinding[] = [];
|
|
604
643
|
if (!analysis.complete) {
|
|
@@ -606,6 +645,7 @@ function inspectHardBlocks(
|
|
|
606
645
|
return findings;
|
|
607
646
|
}
|
|
608
647
|
|
|
648
|
+
let activeCwd = cwd;
|
|
609
649
|
for (const stage of analysis.stages) {
|
|
610
650
|
const argv = effectiveArgv(stage.argv);
|
|
611
651
|
const name = commandName(argv[0]);
|
|
@@ -643,27 +683,51 @@ function inspectHardBlocks(
|
|
|
643
683
|
const recursive = argv.slice(1).some((arg) => arg === "--recursive" || /^-[^-]*r/i.test(arg));
|
|
644
684
|
const force = argv.slice(1).some((arg) => arg === "--force" || /^-[^-]*f/i.test(arg));
|
|
645
685
|
const broad = operands.some((arg) => [".", "..", "*", "./*", ".\\*", "/", "\\"].includes(arg)
|
|
646
|
-
|| /[*?\[]/.test(arg) ||
|
|
686
|
+
|| /[*?\[]/.test(arg) || isAllowedRoot(arg, activeCwd, projectCwd, allowedPaths))
|
|
647
687
|
|| (recursive && force && operands.length !== 1);
|
|
648
688
|
if (broad) addFinding(findings, { code: "broad-deletion", severity: "hard-block", message: "deletion target is broad or recursive across multiple paths", stage: stage.index });
|
|
649
689
|
}
|
|
650
690
|
|
|
651
691
|
const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
|
|
652
692
|
for (const value of pathOperands(effectiveStage)) {
|
|
653
|
-
if (
|
|
654
|
-
if (isExactPosixNullDevice(value,
|
|
693
|
+
if (isCredentialSensitivePath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
|
|
694
|
+
if (isExactPosixNullDevice(value, activeCwd)) continue;
|
|
655
695
|
if (value.startsWith("~")) {
|
|
656
696
|
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path is outside the project boundary", stage: stage.index, path: value });
|
|
657
697
|
continue;
|
|
658
698
|
}
|
|
659
|
-
const resolved = normalizedAbsolute(value,
|
|
699
|
+
const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
|
|
700
|
+
const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
|
|
660
701
|
if (!resolved.inside) {
|
|
661
702
|
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "path resolves outside the project boundary", stage: stage.index, path: value });
|
|
703
|
+
} else if (rootProblem === "missing") {
|
|
704
|
+
addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
|
|
705
|
+
} else if (rootProblem === "link") {
|
|
706
|
+
addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "additional Check mode path became a symlink or junction", stage: stage.index, path: value });
|
|
662
707
|
} else if (symlinkEscapes(resolved.absolute, resolved.root, fs)) {
|
|
663
708
|
addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "path follows a symlink outside the project boundary or cannot be verified", stage: stage.index, path: value });
|
|
664
709
|
}
|
|
665
710
|
}
|
|
666
711
|
|
|
712
|
+
if (["cd", "chdir", "set-location"].includes(name)) {
|
|
713
|
+
const targets = pathOperands(effectiveStage);
|
|
714
|
+
const target = targets.length === 1
|
|
715
|
+
? normalizedAbsolute(targets[0]!, activeCwd, projectCwd, allowedPaths)
|
|
716
|
+
: undefined;
|
|
717
|
+
if ((stage.operatorAfter === ";" || stage.operatorAfter === "newline")
|
|
718
|
+
&& stage.index < analysis.stages.length - 1) {
|
|
719
|
+
addFinding(findings, {
|
|
720
|
+
code: "outside-project",
|
|
721
|
+
severity: "hard-block",
|
|
722
|
+
message: "an unconditional directory change before another command cannot be bounded safely",
|
|
723
|
+
stage: stage.index,
|
|
724
|
+
path: targets[0],
|
|
725
|
+
});
|
|
726
|
+
} else if (stage.operatorAfter === "&&" && target?.inside) {
|
|
727
|
+
activeCwd = target.absolute;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
667
731
|
if (depth < 2) {
|
|
668
732
|
const nestedCommands = stage.substitutions.map((item) => item.text);
|
|
669
733
|
const lowerName = name.toLowerCase();
|
|
@@ -672,7 +736,7 @@ function inspectHardBlocks(
|
|
|
672
736
|
: SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
|
|
673
737
|
if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
|
|
674
738
|
for (const nestedCommand of nestedCommands) {
|
|
675
|
-
const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd);
|
|
739
|
+
const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd, allowedPaths);
|
|
676
740
|
for (const finding of nestedFindings) {
|
|
677
741
|
addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
|
|
678
742
|
}
|
|
@@ -795,7 +859,14 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
|
|
|
795
859
|
const profile = options.profile ?? "balanced";
|
|
796
860
|
const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
|
|
797
861
|
const analysis = analyzeBashCommand(options.command, limits);
|
|
798
|
-
const findings = inspectHardBlocks(
|
|
862
|
+
const findings = inspectHardBlocks(
|
|
863
|
+
analysis,
|
|
864
|
+
options.cwd,
|
|
865
|
+
options.fileSystem ?? nodeFileSystem,
|
|
866
|
+
0,
|
|
867
|
+
options.cwd,
|
|
868
|
+
options.allowedPaths,
|
|
869
|
+
);
|
|
799
870
|
if (findings.some((item) => item.severity === "hard-block")) {
|
|
800
871
|
return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
|
|
801
872
|
}
|
|
@@ -881,8 +952,11 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
881
952
|
};
|
|
882
953
|
const projectCwd = options.projectCwd ?? options.cwd;
|
|
883
954
|
const fs = options.fileSystem ?? nodeFileSystem;
|
|
884
|
-
const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd);
|
|
885
|
-
const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd);
|
|
955
|
+
const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd, options.allowedPaths);
|
|
956
|
+
const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
|
|
957
|
+
const executionRootProblem = executionDirectory.additional
|
|
958
|
+
? additionalRootProblem(executionDirectory.root, fs)
|
|
959
|
+
: undefined;
|
|
886
960
|
if (!executionDirectory.inside) {
|
|
887
961
|
addFinding(findings, {
|
|
888
962
|
code: "outside-project",
|
|
@@ -890,6 +964,20 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
|
|
|
890
964
|
message: "execution cwd resolves outside the project boundary",
|
|
891
965
|
path: options.cwd,
|
|
892
966
|
});
|
|
967
|
+
} else if (executionRootProblem === "missing") {
|
|
968
|
+
addFinding(findings, {
|
|
969
|
+
code: "outside-project",
|
|
970
|
+
severity: "hard-block",
|
|
971
|
+
message: "additional Check mode path no longer exists",
|
|
972
|
+
path: options.cwd,
|
|
973
|
+
});
|
|
974
|
+
} else if (executionRootProblem === "link") {
|
|
975
|
+
addFinding(findings, {
|
|
976
|
+
code: "escaping-symlink",
|
|
977
|
+
severity: "hard-block",
|
|
978
|
+
message: "additional Check mode path became a symlink or junction",
|
|
979
|
+
path: options.cwd,
|
|
980
|
+
});
|
|
893
981
|
} else if (symlinkEscapes(executionDirectory.absolute, executionDirectory.root, fs)) {
|
|
894
982
|
addFinding(findings, {
|
|
895
983
|
code: "escaping-symlink",
|
package/src/commands.ts
CHANGED
|
@@ -25,6 +25,10 @@ export const COMMANDS: Command[] = [
|
|
|
25
25
|
name: "/login",
|
|
26
26
|
description: "Add or update a provider login",
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
name: "/check-path",
|
|
30
|
+
description: "Manage additional Check mode directory roots",
|
|
31
|
+
},
|
|
28
32
|
{
|
|
29
33
|
name: "/triggers",
|
|
30
34
|
description: "Manage external triggers",
|
package/src/help-popup.tsx
CHANGED
|
@@ -55,6 +55,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
55
55
|
["/clear", "Start a fresh session"],
|
|
56
56
|
["/history", "Browse saved sessions"],
|
|
57
57
|
["/login", "Add or update a provider"],
|
|
58
|
+
["/check-path", "Manage extra Check mode paths"],
|
|
58
59
|
["/triggers", "Manage external triggers"],
|
|
59
60
|
["/worktree", "Create a managed worktree"],
|
|
60
61
|
["Tab", "Complete a command preview"],
|
package/src/main.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { mkdirSync } from "node:fs";
|
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { App } from "./app";
|
|
13
13
|
import { AGENT_DIR, AUTH_PATH, MODELS_PATH, sessionDir } from "./config";
|
|
14
|
-
import { loadSettings } from "./settings";
|
|
14
|
+
import { checkPathsForProject, loadSettings } from "./settings";
|
|
15
15
|
import { installWebSearch, webSearch } from "./web-search";
|
|
16
16
|
import { setWritingStyle, writingStyleExtension } from "./writing-style";
|
|
17
17
|
import {
|
|
@@ -59,7 +59,11 @@ export async function start(options: StartupOptions): Promise<void> {
|
|
|
59
59
|
const spawnPreviewManager = new SpawnPreviewManager();
|
|
60
60
|
const sessionHistoryIndex = new SessionHistoryIndex();
|
|
61
61
|
const messageCacheController = new MessageCacheController(process.cwd());
|
|
62
|
-
setCheckModeConfig({
|
|
62
|
+
setCheckModeConfig({
|
|
63
|
+
profile: settings.checkMode,
|
|
64
|
+
model: settings.checkModel,
|
|
65
|
+
additionalPaths: checkPathsForProject(settings, process.cwd()),
|
|
66
|
+
});
|
|
63
67
|
const checkApprovalCoordinator = new CheckApprovalCoordinator();
|
|
64
68
|
const checkApprovalStore = new CheckApprovalStore();
|
|
65
69
|
const mainCheckModeExtension = createCheckModeExtension(modelRuntime, undefined, {
|
package/src/settings-popup.tsx
CHANGED
|
@@ -15,6 +15,7 @@ export type SettingRowId =
|
|
|
15
15
|
| "explanationStrength"
|
|
16
16
|
| "checkMode"
|
|
17
17
|
| "checkModel"
|
|
18
|
+
| "checkPaths"
|
|
18
19
|
| "clearCheckApprovals"
|
|
19
20
|
| "thinkingLevel"
|
|
20
21
|
| "showThinking"
|
|
@@ -44,6 +45,7 @@ export const SETTINGS_ROWS: readonly SettingRow[] = [
|
|
|
44
45
|
{ id: "webSearch", label: "Web search", category: "Agent", keywords: "internet hosted codex provider", description: "Allow hosted web search on supported Codex providers. Other providers are unchanged." },
|
|
45
46
|
{ id: "checkMode", label: "Check mode", category: "Safety", keywords: "profile strict balanced ask verify tools hard block approval bash edit patch", description: "Choose off, strict, balanced, or ask. Hard security rules apply to every active profile." },
|
|
46
47
|
{ id: "checkModel", label: "Check model", category: "Safety", keywords: "verifier tools safety structured verdict model", description: "Select the separate verifier model used for ambiguous Check mode calls." },
|
|
48
|
+
{ id: "checkPaths", label: "Allowed paths", category: "Safety", keywords: "additional directories roots boundary command", description: "Use /check-path to manage extra directory roots allowed by Check mode for this project." },
|
|
47
49
|
{ id: "clearCheckApprovals", label: "Clear approvals", category: "Safety", keywords: "remove reset exact project approvals ask", description: "Remove exact Check mode approvals stored for this project." },
|
|
48
50
|
];
|
|
49
51
|
|
package/src/settings.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { AGENT_DIR } from "./config";
|
|
4
4
|
import { DEFAULT_CHECK_MODEL } from "./check-mode";
|
|
5
|
+
import { projectStorageKey } from "./platform";
|
|
5
6
|
import { isWritingStyle, type WritingStyle } from "./writing-style";
|
|
6
7
|
import {
|
|
7
8
|
isExplanationStrength,
|
|
@@ -15,6 +16,39 @@ export type CheckModeProfile = (typeof CHECK_MODE_PROFILES)[number];
|
|
|
15
16
|
export const MIN_ACTIVE_SUBAGENTS = 1;
|
|
16
17
|
export const MAX_ACTIVE_SUBAGENTS = 25;
|
|
17
18
|
export const DEFAULT_MAX_ACTIVE_SUBAGENTS = 10;
|
|
19
|
+
export const MAX_CHECK_PATHS_PER_PROJECT = 16;
|
|
20
|
+
|
|
21
|
+
export type CheckPathsByProject = Record<string, string[]>;
|
|
22
|
+
|
|
23
|
+
export function normalizeCheckPathsByProject(value: unknown): CheckPathsByProject {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
25
|
+
const normalized: CheckPathsByProject = {};
|
|
26
|
+
for (const [project, paths] of Object.entries(value)) {
|
|
27
|
+
if (!project || !Array.isArray(paths)) continue;
|
|
28
|
+
const entries = [...new Set(paths.filter((path): path is string =>
|
|
29
|
+
typeof path === "string" && path.length > 0 && !path.includes("\0"),
|
|
30
|
+
))].slice(0, MAX_CHECK_PATHS_PER_PROJECT);
|
|
31
|
+
if (entries.length > 0) normalized[project] = entries;
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkPathsForProject(settings: Pick<PumSettings, "checkPaths">, cwd: string): string[] {
|
|
37
|
+
return [...(settings.checkPaths?.[projectStorageKey(cwd)] ?? [])];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function withCheckPathsForProject(
|
|
41
|
+
settings: PumSettings,
|
|
42
|
+
cwd: string,
|
|
43
|
+
paths: readonly string[],
|
|
44
|
+
): PumSettings {
|
|
45
|
+
const key = projectStorageKey(cwd);
|
|
46
|
+
const checkPaths = { ...(settings.checkPaths ?? {}) };
|
|
47
|
+
const normalized = [...new Set(paths)].slice(0, MAX_CHECK_PATHS_PER_PROJECT);
|
|
48
|
+
if (normalized.length > 0) checkPaths[key] = normalized;
|
|
49
|
+
else delete checkPaths[key];
|
|
50
|
+
return { ...settings, checkPaths };
|
|
51
|
+
}
|
|
18
52
|
|
|
19
53
|
export function normalizeMaxActiveSubagents(value: unknown): number {
|
|
20
54
|
return typeof value === "number"
|
|
@@ -49,6 +83,8 @@ export type PumSettings = {
|
|
|
49
83
|
explanationStrength: ExplanationStrength;
|
|
50
84
|
checkMode: CheckModeProfile;
|
|
51
85
|
checkModel: string;
|
|
86
|
+
/** Additional canonical directory roots allowed by Check mode, keyed by launch project. */
|
|
87
|
+
checkPaths?: CheckPathsByProject;
|
|
52
88
|
maxActiveSubagents: number;
|
|
53
89
|
};
|
|
54
90
|
|
|
@@ -64,6 +100,7 @@ const DEFAULTS: PumSettings = {
|
|
|
64
100
|
explanationStrength: "simple",
|
|
65
101
|
checkMode: "off",
|
|
66
102
|
checkModel: DEFAULT_CHECK_MODEL,
|
|
103
|
+
checkPaths: {},
|
|
67
104
|
maxActiveSubagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
|
|
68
105
|
};
|
|
69
106
|
|
|
@@ -91,6 +128,7 @@ export function normalizeSettings(parsed: unknown): PumSettings {
|
|
|
91
128
|
typeof merged.checkModel === "string" && merged.checkModel.includes("/")
|
|
92
129
|
? merged.checkModel
|
|
93
130
|
: DEFAULTS.checkModel,
|
|
131
|
+
checkPaths: normalizeCheckPathsByProject(merged.checkPaths),
|
|
94
132
|
maxActiveSubagents: normalizeMaxActiveSubagents(merged.maxActiveSubagents),
|
|
95
133
|
};
|
|
96
134
|
}
|
package/src/subagents/manager.ts
CHANGED
|
@@ -590,13 +590,13 @@ export class SubagentManager {
|
|
|
590
590
|
break;
|
|
591
591
|
case "tool_execution_end":
|
|
592
592
|
this.patchTool(record, event.toolCallId, {
|
|
593
|
-
state: isRejectedToolResult(event.result)
|
|
593
|
+
state: isRejectedToolResult(event.result, event.toolCallId)
|
|
594
594
|
? "rejected"
|
|
595
595
|
: event.isError
|
|
596
596
|
? "error"
|
|
597
597
|
: "ok",
|
|
598
|
-
detail: isRejectedToolResult(event.result)
|
|
599
|
-
? rejectedToolReason(event.result)
|
|
598
|
+
detail: isRejectedToolResult(event.result, event.toolCallId)
|
|
599
|
+
? rejectedToolReason(event.result, event.toolCallId)
|
|
600
600
|
: event.toolName === "edit" || event.toolName === "apply_patch"
|
|
601
601
|
? editCounts(event.result)
|
|
602
602
|
: event.toolName === "questionnaire"
|
package/src/transcript.tsx
CHANGED
|
@@ -375,7 +375,7 @@ export function ToolLine({
|
|
|
375
375
|
const bodyChunks = call.arg
|
|
376
376
|
? [fg(argColor)(call.arg)]
|
|
377
377
|
: [fg(toolColor)(call.name)];
|
|
378
|
-
if (call.detail) bodyChunks.push(fg(detailColor)(` ${call.detail}`));
|
|
378
|
+
if (call.detail && !rejected) bodyChunks.push(fg(detailColor)(` ${call.detail}`));
|
|
379
379
|
|
|
380
380
|
const caret = useBlinkingText({
|
|
381
381
|
chunks: bodyChunks,
|
|
@@ -385,36 +385,49 @@ export function ToolLine({
|
|
|
385
385
|
});
|
|
386
386
|
|
|
387
387
|
return (
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
ref={workingCaret ? caret : undefined}
|
|
397
|
-
content={workingCaret ? undefined : new StyledText(bodyChunks)}
|
|
398
|
-
selectable
|
|
399
|
-
wrapMode="word"
|
|
400
|
-
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
401
|
-
/>
|
|
402
|
-
</box>
|
|
403
|
-
{/* The transcript scrollbox uses two padding columns and one pinned
|
|
404
|
-
scrollbar column. The row then uses a two-column gutter and a
|
|
405
|
-
one-column state marker. Bash reserves one additional column before
|
|
406
|
-
the marker so commands wrap before the terminal-edge boundary. */}
|
|
407
|
-
<box style={{ width: call.name === "bash" ? 2 : 1, flexShrink: 0 }} />
|
|
408
|
-
<box style={{ width: 1, flexShrink: 0 }}>
|
|
409
|
-
{call.state === "running" ? (
|
|
410
|
-
<text ref={spinner} fg={theme.accent} />
|
|
411
|
-
) : (
|
|
388
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
389
|
+
<Row
|
|
390
|
+
glyph={GUTTER}
|
|
391
|
+
glyphColor={toolColor}
|
|
392
|
+
background={rejected ? theme.rejectionBg : undefined}
|
|
393
|
+
>
|
|
394
|
+
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
395
|
+
{prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
|
|
412
396
|
<text
|
|
413
|
-
|
|
414
|
-
|
|
397
|
+
ref={workingCaret ? caret : undefined}
|
|
398
|
+
content={workingCaret ? undefined : new StyledText(bodyChunks)}
|
|
399
|
+
selectable
|
|
400
|
+
wrapMode="word"
|
|
401
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
415
402
|
/>
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
403
|
+
</box>
|
|
404
|
+
{/* The transcript scrollbox uses two padding columns and one pinned
|
|
405
|
+
scrollbar column. The row then uses a two-column gutter and a
|
|
406
|
+
one-column state marker. Bash reserves one additional column before
|
|
407
|
+
the marker so commands wrap before the terminal-edge boundary. */}
|
|
408
|
+
<box style={{ width: call.name === "bash" ? 2 : 1, flexShrink: 0 }} />
|
|
409
|
+
<box style={{ width: 1, flexShrink: 0 }}>
|
|
410
|
+
{call.state === "running" ? (
|
|
411
|
+
<text ref={spinner} fg={theme.accent} />
|
|
412
|
+
) : (
|
|
413
|
+
<text
|
|
414
|
+
content={toolStateGlyph(call.state)}
|
|
415
|
+
fg={call.state === "ok" ? theme.success : rejected ? theme.rejection : theme.error}
|
|
416
|
+
/>
|
|
417
|
+
)}
|
|
418
|
+
</box>
|
|
419
|
+
</Row>
|
|
420
|
+
{rejected && call.detail ? (
|
|
421
|
+
<Row glyph={GUTTER} glyphColor={theme.rejection} background={theme.rejectionBg}>
|
|
422
|
+
<text
|
|
423
|
+
content={call.detail}
|
|
424
|
+
fg={theme.rejection}
|
|
425
|
+
selectable
|
|
426
|
+
wrapMode="word"
|
|
427
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
428
|
+
/>
|
|
429
|
+
</Row>
|
|
430
|
+
) : null}
|
|
431
|
+
</box>
|
|
419
432
|
);
|
|
420
433
|
}
|