pum-agent 0.2.0-beta.3 → 0.2.1-beta.1

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/README.md CHANGED
@@ -129,7 +129,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
129
129
  | `Ctrl+C` | Clear the selected non-empty draft; on an empty draft, press twice to quit |
130
130
  | `?` | Show all controls when the prompt is empty |
131
131
 
132
- Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
132
+ Useful commands include `/login`, `/history`, `/triggers`, `/check-path`, `/clear`, `/compress`, and `/worktree`.
133
133
 
134
134
  ### Copy transcript text
135
135
 
@@ -210,6 +210,8 @@ Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_pa
210
210
 
211
211
  Every active profile hard-blocks project escape, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use the exception.
212
212
 
213
+ Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. Bash, edit, and external-trigger checks can use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
214
+
213
215
  For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a valid Balanced call.
214
216
 
215
217
  Ask mode can allow an exact call once, for the current session, or for the current project. Approvals match the authoritative main or child identity, tool, verifier model, project, and canonical complete input. Chat text is not approval. Use **Clear approvals** in Settings to remove project approvals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.1-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/animation.tsx CHANGED
@@ -313,9 +313,9 @@ export function useBlinkingText(opts: {
313
313
  return ref;
314
314
  }
315
315
 
316
- /** Keep both caret frames equivalent to the Markdown parser. */
317
- export function markdownCaretContent(text: string, visible: boolean): string {
318
- return text + (visible ? CARET : CARET_PLACEHOLDER);
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 blinking caret at the end of incrementally rendered Markdown. */
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
- // React applies this value in the same commit as each transcript delta.
368
- // The direct frame writes only change the width-stable caret glyph.
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, caretVisible.current) : 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({ profile: next.checkMode, model: next.checkModel });
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 (worktreeCommand) {
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
- return Boolean(details && typeof details === "object"
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 (!details || typeof details !== "object") return undefined;
52
- const reason = (details as Record<string, unknown>).pumRejectionReason;
53
- return typeof reason === "string" && reason.trim() ? reason : undefined;
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 ? config : { profile: config.enabled ? "strict" : "off", model: config.model };
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(call.toolName, call.input, call.cwd, profile, call.context);
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
  }
@@ -1,9 +1,15 @@
1
1
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
3
  import { lstat, readFile, realpath } from "node:fs/promises";
4
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, relative, resolve, sep } from "node:path";
5
5
  import { previewApplyPatch } from "./apply-patch";
6
6
  import type { CheckedToolName } from "./check-approvals";
7
+ import {
8
+ canonicalPathIdentityAllowMissing,
9
+ isPathInsideOrSame,
10
+ pathIdentity,
11
+ pathsHaveSameIdentity,
12
+ } from "./platform";
7
13
 
8
14
  export type MutationSensitivity = {
9
15
  executable: boolean;
@@ -66,32 +72,48 @@ function windowsAbsolute(path: string): boolean {
66
72
  return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path) || /^\/\//.test(path);
67
73
  }
68
74
 
69
- function insideRoot(root: string, path: string): boolean {
70
- const rel = relative(root, path);
71
- return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
72
- }
73
-
74
- async function validateEditPath(cwd: string, inputPath: string): Promise<{ root: string; absolute: string; display: string; mode: number }> {
75
+ async function validateEditPath(
76
+ cwd: string,
77
+ inputPath: string,
78
+ allowedPaths: readonly string[],
79
+ ): Promise<{ root: string; absolute: string; display: string; mode: number }> {
75
80
  if (!inputPath || inputPath.includes("\0")) throw new Error("Edit path is invalid");
76
81
  if (windowsAbsolute(inputPath) && process.platform !== "win32") {
77
82
  throw new Error(`Edit path is outside the project: ${inputPath}`);
78
83
  }
79
- const root = await realpath(cwd);
80
- const absolute = resolve(root, inputPath);
81
- if (!insideRoot(root, absolute)) throw new Error(`Edit path is outside the project: ${inputPath}`);
84
+ const projectRoot = await realpath(cwd);
85
+ const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
86
+ const absolute = resolve(projectRoot, inputPath);
87
+ const sortedRoots = roots.sort((first, second) => second.length - first.length);
88
+ let root = sortedRoots.find((candidate) => isPathInsideOrSame(candidate, absolute));
89
+ if (!root) {
90
+ let targetIdentity: string;
91
+ try {
92
+ targetIdentity = await canonicalPathIdentityAllowMissing(absolute);
93
+ } catch {
94
+ throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
95
+ }
96
+ root = sortedRoots.find((candidate) => isPathInsideOrSame(pathIdentity(candidate), targetIdentity));
97
+ }
98
+ if (!root) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
99
+ const canonical = await realpath(absolute);
82
100
 
83
- let component = root;
84
- const rel = relative(root, absolute);
85
- for (const part of rel.split(sep).filter(Boolean)) {
86
- component = resolve(component, part);
101
+ let component = absolute;
102
+ while (true) {
87
103
  const metadata = await lstat(component);
88
104
  if (metadata.isSymbolicLink()) throw new Error(`Edit path contains an escaping link or junction: ${inputPath}`);
105
+ if (await pathsHaveSameIdentity(component, root)) break;
106
+ const parent = dirname(component);
107
+ if (parent === component) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
108
+ component = parent;
89
109
  }
90
- const canonical = await realpath(absolute);
91
- if (!insideRoot(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
110
+ if (!isPathInsideOrSame(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
92
111
  const metadata = await lstat(absolute);
93
112
  if (!metadata.isFile()) throw new Error(`Edit path is not a file: ${inputPath}`);
94
- return { root, absolute, display: relative(root, absolute).split(sep).join("/"), mode: metadata.mode };
113
+ const display = isPathInsideOrSame(projectRoot, canonical)
114
+ ? relative(projectRoot, canonical).split(sep).join("/")
115
+ : absolute;
116
+ return { root, absolute, display, mode: metadata.mode };
95
117
  }
96
118
 
97
119
  function occurrences(content: string, needle: string): number[] {
@@ -144,7 +166,7 @@ function completeContentMetadata(patch: string): Pick<MutationPreview, "contentC
144
166
  };
145
167
  }
146
168
 
147
- async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview> {
169
+ async function previewEdit(cwd: string, input: unknown, allowedPaths: readonly string[]): Promise<MutationPreview> {
148
170
  if (!input || typeof input !== "object") throw new Error("Edit input is invalid");
149
171
  const value = input as { path?: unknown; edits?: unknown; oldText?: unknown; newText?: unknown };
150
172
  if (typeof value.path !== "string") throw new Error("Edit path is invalid");
@@ -162,7 +184,7 @@ async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview
162
184
  && typeof (edit as any).newText === "string",
163
185
  )) throw new Error("Edit replacements are invalid");
164
186
 
165
- const validated = await validateEditPath(cwd, value.path);
187
+ const validated = await validateEditPath(cwd, value.path, allowedPaths);
166
188
  const buffer = await readFile(validated.absolute);
167
189
  let content: string;
168
190
  try {
@@ -204,8 +226,9 @@ export async function previewMutation(
204
226
  toolName: CheckedToolName,
205
227
  cwd: string,
206
228
  input: unknown,
229
+ allowedPaths: readonly string[] = [],
207
230
  ): Promise<MutationPreview | undefined> {
208
- if (toolName === "edit") return previewEdit(cwd, input);
231
+ if (toolName === "edit") return previewEdit(cwd, input, allowedPaths);
209
232
  if (toolName !== "apply_patch") return undefined;
210
233
  if (!input || typeof input !== "object" || typeof (input as { patch?: unknown }).patch !== "string") {
211
234
  throw new Error("Apply patch input is invalid");
@@ -0,0 +1,132 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { AGENT_DIR } from "./config";
5
+ import { isCredentialSensitivePath } from "./check-policy";
6
+ import {
7
+ canonicalPathIdentityAllowMissing,
8
+ isPathInsideOrSame,
9
+ pathIdentity,
10
+ } from "./platform";
11
+ import {
12
+ checkPathsForProject,
13
+ MAX_CHECK_PATHS_PER_PROJECT,
14
+ type PumSettings,
15
+ withCheckPathsForProject,
16
+ } from "./settings";
17
+
18
+ export type CheckPathCommand =
19
+ | { action: "list" }
20
+ | { action: "clear" }
21
+ | { action: "add" | "remove"; path: string };
22
+
23
+ function unquote(value: string): string {
24
+ const trimmed = value.trim();
25
+ if (trimmed.length >= 2) {
26
+ const first = trimmed[0];
27
+ if ((first === "\"" || first === "'") && trimmed.at(-1) === first) {
28
+ return trimmed.slice(1, -1);
29
+ }
30
+ }
31
+ return trimmed;
32
+ }
33
+
34
+ export function parseCheckPathCommand(input: string): CheckPathCommand | undefined {
35
+ const match = /^\/check-path(?:\s+(.*))?$/s.exec(input.trim());
36
+ if (!match) return undefined;
37
+ const argumentsText = match[1]?.trim() ?? "";
38
+ if (!argumentsText || argumentsText === "list") return { action: "list" };
39
+ if (argumentsText === "clear") return { action: "clear" };
40
+ const action = /^(add|remove)\s+([\s\S]+)$/.exec(argumentsText);
41
+ if (!action) throw new Error("usage: /check-path [list|add <directory>|remove <directory>|clear]");
42
+ const path = unquote(action[2]!);
43
+ if (!path || path.includes("\0")) throw new Error("Check path is invalid");
44
+ return { action: action[1] as "add" | "remove", path };
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 (isPathInsideOrSame(canonical, agentDirectory) || isPathInsideOrSame(agentDirectory, canonical)) {
61
+ throw new Error("Check path cannot contain or enter PUM's configuration directory");
62
+ }
63
+ if (isPathInsideOrSame(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 canonicalPathIdentityAllowMissing(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 (isPathInsideOrSame(project, canonical)) {
106
+ throw new Error("The directory is already inside the project boundary");
107
+ }
108
+ const identity = pathIdentity(canonical);
109
+ if (paths.some((path) => pathIdentity(path) === identity)) {
110
+ throw new Error(`Check path is already allowed: ${canonical}`);
111
+ }
112
+ if (paths.length >= MAX_CHECK_PATHS_PER_PROJECT) {
113
+ throw new Error(`Check mode allows at most ${MAX_CHECK_PATHS_PER_PROJECT} additional paths per project`);
114
+ }
115
+ const nextPaths = [...paths, canonical];
116
+ return {
117
+ settings: withCheckPathsForProject(settings, cwd, nextPaths),
118
+ paths: nextPaths,
119
+ message: `allowed Check mode path: ${canonical}`,
120
+ };
121
+ }
122
+
123
+ const identity = pathIdentity(canonical);
124
+ const index = paths.findIndex((path) => pathIdentity(path) === identity);
125
+ if (index < 0) throw new Error(`Check path is not configured: ${canonical}`);
126
+ const nextPaths = paths.filter((_path, candidate) => candidate !== index);
127
+ return {
128
+ settings: withCheckPathsForProject(settings, cwd, nextPaths),
129
+ paths: nextPaths,
130
+ message: `removed Check mode path: ${canonical}`,
131
+ };
132
+ }
@@ -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(value: string, cwd: string, projectCwd = cwd): { absolute: string; root: string; inside: boolean } {
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 root = flavor.resolve(projectCwd);
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 { absolute, root, inside };
543
+ return {
544
+ absolute,
545
+ root,
546
+ inside,
547
+ additional: flavor.relative(flavor.resolve(projectCwd), root) !== "",
548
+ };
528
549
  }
529
550
 
530
- function isProjectRoot(value: string, cwd: string): boolean {
551
+ function isAllowedRoot(value: string, cwd: string, projectCwd: string, allowedPaths: readonly string[]): boolean {
531
552
  const flavor = pathFlavor(value, cwd);
532
- const resolved = normalizedAbsolute(value, cwd);
533
- return flavor.relative(resolved.root, resolved.absolute) === "";
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 credentialPath(value: string): boolean {
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,27 @@ 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
+ const flavor = pathFlavor(root, root);
621
+ const resolved = flavor.resolve(root);
622
+ const parsed = flavor.parse(resolved);
623
+ let component = parsed.root;
624
+ for (const part of resolved.slice(parsed.root.length).split(/[\\/]+/).filter(Boolean)) {
625
+ component = flavor.join(component, part);
626
+ if (fs.isSymbolicLink(component)) return "link";
627
+ }
628
+ fs.realpath(root);
629
+ return undefined;
630
+ } catch {
631
+ return "link";
632
+ }
633
+ }
634
+
592
635
  function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding): void {
593
636
  if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
594
637
  }
@@ -599,6 +642,7 @@ function inspectHardBlocks(
599
642
  fs: CheckPolicyFileSystem,
600
643
  depth = 0,
601
644
  projectCwd = cwd,
645
+ allowedPaths: readonly string[] = [],
602
646
  ): CheckPolicyFinding[] {
603
647
  const findings: CheckPolicyFinding[] = [];
604
648
  if (!analysis.complete) {
@@ -606,6 +650,7 @@ function inspectHardBlocks(
606
650
  return findings;
607
651
  }
608
652
 
653
+ let activeCwd = cwd;
609
654
  for (const stage of analysis.stages) {
610
655
  const argv = effectiveArgv(stage.argv);
611
656
  const name = commandName(argv[0]);
@@ -643,27 +688,51 @@ function inspectHardBlocks(
643
688
  const recursive = argv.slice(1).some((arg) => arg === "--recursive" || /^-[^-]*r/i.test(arg));
644
689
  const force = argv.slice(1).some((arg) => arg === "--force" || /^-[^-]*f/i.test(arg));
645
690
  const broad = operands.some((arg) => [".", "..", "*", "./*", ".\\*", "/", "\\"].includes(arg)
646
- || /[*?\[]/.test(arg) || isProjectRoot(arg, projectCwd))
691
+ || /[*?\[]/.test(arg) || isAllowedRoot(arg, activeCwd, projectCwd, allowedPaths))
647
692
  || (recursive && force && operands.length !== 1);
648
693
  if (broad) addFinding(findings, { code: "broad-deletion", severity: "hard-block", message: "deletion target is broad or recursive across multiple paths", stage: stage.index });
649
694
  }
650
695
 
651
696
  const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
652
697
  for (const value of pathOperands(effectiveStage)) {
653
- if (credentialPath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
654
- if (isExactPosixNullDevice(value, cwd)) continue;
698
+ if (isCredentialSensitivePath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
699
+ if (isExactPosixNullDevice(value, activeCwd)) continue;
655
700
  if (value.startsWith("~")) {
656
701
  addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path is outside the project boundary", stage: stage.index, path: value });
657
702
  continue;
658
703
  }
659
- const resolved = normalizedAbsolute(value, cwd, projectCwd);
704
+ const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
705
+ const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
660
706
  if (!resolved.inside) {
661
707
  addFinding(findings, { code: "outside-project", severity: "hard-block", message: "path resolves outside the project boundary", stage: stage.index, path: value });
708
+ } else if (rootProblem === "missing") {
709
+ addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
710
+ } else if (rootProblem === "link") {
711
+ addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "additional Check mode path became a symlink or junction", stage: stage.index, path: value });
662
712
  } else if (symlinkEscapes(resolved.absolute, resolved.root, fs)) {
663
713
  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
714
  }
665
715
  }
666
716
 
717
+ if (["cd", "chdir", "set-location"].includes(name)) {
718
+ const targets = pathOperands(effectiveStage);
719
+ const target = targets.length === 1
720
+ ? normalizedAbsolute(targets[0]!, activeCwd, projectCwd, allowedPaths)
721
+ : undefined;
722
+ if ((stage.operatorAfter === ";" || stage.operatorAfter === "newline")
723
+ && stage.index < analysis.stages.length - 1) {
724
+ addFinding(findings, {
725
+ code: "outside-project",
726
+ severity: "hard-block",
727
+ message: "an unconditional directory change before another command cannot be bounded safely",
728
+ stage: stage.index,
729
+ path: targets[0],
730
+ });
731
+ } else if (stage.operatorAfter === "&&" && target?.inside) {
732
+ activeCwd = target.absolute;
733
+ }
734
+ }
735
+
667
736
  if (depth < 2) {
668
737
  const nestedCommands = stage.substitutions.map((item) => item.text);
669
738
  const lowerName = name.toLowerCase();
@@ -672,7 +741,7 @@ function inspectHardBlocks(
672
741
  : SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
673
742
  if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
674
743
  for (const nestedCommand of nestedCommands) {
675
- const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd);
744
+ const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd, allowedPaths);
676
745
  for (const finding of nestedFindings) {
677
746
  addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
678
747
  }
@@ -795,7 +864,14 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
795
864
  const profile = options.profile ?? "balanced";
796
865
  const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
797
866
  const analysis = analyzeBashCommand(options.command, limits);
798
- const findings = inspectHardBlocks(analysis, options.cwd, options.fileSystem ?? nodeFileSystem);
867
+ const findings = inspectHardBlocks(
868
+ analysis,
869
+ options.cwd,
870
+ options.fileSystem ?? nodeFileSystem,
871
+ 0,
872
+ options.cwd,
873
+ options.allowedPaths,
874
+ );
799
875
  if (findings.some((item) => item.severity === "hard-block")) {
800
876
  return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
801
877
  }
@@ -881,8 +957,11 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
881
957
  };
882
958
  const projectCwd = options.projectCwd ?? options.cwd;
883
959
  const fs = options.fileSystem ?? nodeFileSystem;
884
- const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd);
885
- const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd);
960
+ const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd, options.allowedPaths);
961
+ const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
962
+ const executionRootProblem = executionDirectory.additional
963
+ ? additionalRootProblem(executionDirectory.root, fs)
964
+ : undefined;
886
965
  if (!executionDirectory.inside) {
887
966
  addFinding(findings, {
888
967
  code: "outside-project",
@@ -890,6 +969,20 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
890
969
  message: "execution cwd resolves outside the project boundary",
891
970
  path: options.cwd,
892
971
  });
972
+ } else if (executionRootProblem === "missing") {
973
+ addFinding(findings, {
974
+ code: "outside-project",
975
+ severity: "hard-block",
976
+ message: "additional Check mode path no longer exists",
977
+ path: options.cwd,
978
+ });
979
+ } else if (executionRootProblem === "link") {
980
+ addFinding(findings, {
981
+ code: "escaping-symlink",
982
+ severity: "hard-block",
983
+ message: "additional Check mode path became a symlink or junction",
984
+ path: options.cwd,
985
+ });
893
986
  } else if (symlinkEscapes(executionDirectory.absolute, executionDirectory.root, fs)) {
894
987
  addFinding(findings, {
895
988
  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",
@@ -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({ profile: settings.checkMode, model: settings.checkModel });
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/platform.ts CHANGED
@@ -64,6 +64,23 @@ export function isPathInside(
64
64
  return relative !== "" && relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
65
65
  }
66
66
 
67
+ export function pathIdentity(
68
+ path: string,
69
+ platform: RuntimePlatform = process.platform,
70
+ ): string {
71
+ const canonical = pathApi(platform).resolve(path);
72
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
73
+ }
74
+
75
+ export function isPathInsideOrSame(
76
+ parent: string,
77
+ candidate: string,
78
+ platform: RuntimePlatform = process.platform,
79
+ ): boolean {
80
+ return pathIdentity(parent, platform) === pathIdentity(candidate, platform)
81
+ || isPathInside(parent, candidate, platform);
82
+ }
83
+
67
84
  export async function canonicalPathIdentity(
68
85
  path: string,
69
86
  platform: RuntimePlatform = process.platform,
@@ -74,6 +91,28 @@ export async function canonicalPathIdentity(
74
91
  return platform === "win32" ? canonical.toLowerCase() : canonical;
75
92
  }
76
93
 
94
+ export async function canonicalPathIdentityAllowMissing(
95
+ path: string,
96
+ platform: RuntimePlatform = process.platform,
97
+ resolvePath: (path: string) => Promise<string> = realpath,
98
+ ): Promise<string> {
99
+ const paths = pathApi(platform);
100
+ const absolute = paths.resolve(path);
101
+ const missing: string[] = [];
102
+ let existing = absolute;
103
+ while (true) {
104
+ try {
105
+ const canonical = await resolvePath(existing);
106
+ return pathIdentity(paths.resolve(canonical, ...missing.reverse()), platform);
107
+ } catch (error) {
108
+ const parent = paths.dirname(existing);
109
+ if (parent === existing || existing === paths.parse(existing).root) throw error;
110
+ missing.push(paths.basename(existing));
111
+ existing = parent;
112
+ }
113
+ }
114
+ }
115
+
77
116
  export async function pathsHaveSameIdentity(
78
117
  first: string,
79
118
  second: string,
@@ -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
  }
@@ -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"
@@ -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
- <Row
389
- glyph={GUTTER}
390
- glyphColor={toolColor}
391
- background={rejected ? theme.rejectionBg : undefined}
392
- >
393
- <box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
394
- {prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
395
- <text
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
- content={toolStateGlyph(call.state)}
414
- fg={call.state === "ok" ? theme.success : rejected ? theme.rejection : theme.error}
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
- </box>
418
- </Row>
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
  }