pum-agent 0.2.0-beta.4 → 0.2.2-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
@@ -23,7 +23,7 @@ pum
23
23
  PUM opens the login panel automatically on the first start.
24
24
 
25
25
  > [!WARNING]
26
- > PUM can read, write, and delete files. PUM can also run commands and supervised trigger processes. Check mode can verify or ask about these operations, but it is not a sandbox. Start PUM only inside a workspace where these actions are acceptable.
26
+ > PUM can read, write, and delete files. Check mode adds deterministic policy checks and can enforce native Bash isolation on supported Linux and Windows hosts. Other tools, extensions, and external triggers still run in the PUM process boundary described below. Review the safeguards and prerequisites before using untrusted workspaces.
27
27
 
28
28
  ## See PUM in action
29
29
 
@@ -46,7 +46,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
46
46
  - **Prompt control:** Steer active work, answer model questionnaires, use an ownership-aware message cache, attach clipboard images, and resume sessions with metadata-rich history.
47
47
  - **External triggers:** Supervise background commands such as `gh run watch` and automatically wake the exact target agent when they exit.
48
48
  - **Provider choice:** Search the providers exposed by pi, or add an OpenAI-compatible custom endpoint.
49
- - **Optional safeguards:** Use strict, balanced, or ask Check mode for `bash`, `edit`, `apply_patch`, and external-trigger process proposals.
49
+ - **Optional safeguards:** Use strict, balanced, or ask Check mode, plus native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox when available.
50
50
  - **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
51
51
 
52
52
  PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [OpenTUI](https://github.com/anomalyco/opentui) for rendering.
@@ -60,7 +60,22 @@ PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [Open
60
60
 
61
61
  Linux and macOS are the primary environments. Windows CI checks the code and Windows path behavior. Native Windows TUI operation remains provisional because it has not been fully validated in a Windows terminal.
62
62
 
63
- On Windows, install Git for Windows. Ensure that `bash.exe` is in `PATH` or remains in its standard location. Use Windows Terminal with PowerShell. Do not use PowerShell ISE.
63
+ On Linux, native Bash sandboxing requires Bubblewrap (`bwrap`) and working unprivileged user namespaces. PUM probes a minimal sandbox launch; finding the executable alone is not sufficient. On Arch Linux, install the prerequisite separately with `sudo pacman -S --needed bubblewrap`.
64
+
65
+ On Windows, install Git for Windows. Ensure that `bash.exe` is in `PATH` or remains in its standard location. Use Windows Terminal with PowerShell. Do not use PowerShell ISE. Native Bash sandboxing uses the optional alpha `@microsoft/mxc-sdk` package and requires its `base-container` CreateProcessInSandbox tier. PUM deliberately rejects the SDK's AppContainer+DACL fallback because it can modify host ACLs.
66
+
67
+ ### Terminal title
68
+
69
+ PUM sets a compact terminal title such as `Pum · working · 2 subagents`. The title reports overall activity and counts only starting or running subagents. PUM clears the title during graceful shutdown.
70
+
71
+ Windows Terminal and common Linux terminal emulators accept the title through OpenTUI. Inside `tmux`, PUM sets the active pane title. To copy that pane title to the outer terminal title, add this configuration:
72
+
73
+ ```tmux
74
+ set -g set-titles on
75
+ set -g set-titles-string '#T'
76
+ ```
77
+
78
+ Keep `allow-set-title` enabled so applications can update the pane title. A `tmux` configuration can replace or suppress application titles. PUM cannot override that server policy.
64
79
 
65
80
  ## Install and start
66
81
 
@@ -129,7 +144,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
129
144
  | `Ctrl+C` | Clear the selected non-empty draft; on an empty draft, press twice to quit |
130
145
  | `?` | Show all controls when the prompt is empty |
131
146
 
132
- Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
147
+ Useful commands include `/login`, `/history`, `/triggers`, `/check-path`, `/clear`, `/compress`, and `/worktree`.
133
148
 
134
149
  ### Copy transcript text
135
150
 
@@ -205,16 +220,36 @@ Trigger events target one exact main or retained child session. A missing sessio
205
220
  Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
206
221
 
207
222
  - **Strict:** Run deterministic hard rules, then require a clear verifier approval.
208
- - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
223
+ - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls and explicit non-sensitive external reads. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
209
224
  - **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
210
225
 
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.
226
+ Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits only explicit, deterministically classified, non-sensitive external reads. 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.
227
+
228
+ 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.
212
229
 
213
230
  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
231
 
215
232
  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.
216
233
 
217
- The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default and is not a sandbox.
234
+ The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default.
235
+
236
+ #### Native Bash sandbox
237
+
238
+ The **Sandbox** setting has three modes:
239
+
240
+ - **Auto:** Enforce the platform sandbox for Bash when available. If probing fails, retain deterministic Check mode and show one process-local warning that is not written to session context.
241
+ - **Require:** Block checked Bash calls unless native enforcement is available.
242
+ - **Off:** Do not sandbox Bash. Check mode policy and approval behavior remain unchanged.
243
+
244
+ Check mode **Off** always uses pi's normal unsandboxed Bash backend. For an active Check mode, PUM recomputes the sandbox policy from the exact approved command, authoritative working directory, configured additional roots, and deterministic access analysis. Model input cannot supply policy fields.
245
+
246
+ The project and configured additional roots are writable. Explicit Balanced external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
247
+
248
+ Network access is denied unless deterministic analysis recognizes an approved network operation. Bubblewrap's host-network mode is all-or-nothing and is **not domain-filtered**. Windows similarly grants or withholds the SDK's broad network capabilities; it does not provide hostname allowlists.
249
+
250
+ The override uses pi's `createBashTool` implementation and custom Bash operations, preserving streaming, truncation, full-output files, rendering, timeout messages, abort handling, shell configuration, and child-tree cleanup. Only Bash commands are routed through this backend. PUM does not sandbox the TUI/model process itself.
251
+
252
+ External triggers preserve direct executable/argument boundaries and continue to use deterministic Check mode, but they are not routed through the native sandbox in this release. The trigger manager's synchronous spawn boundary does not carry the exact approved policy object into execution; silently recomputing a second process policy there would weaken approval identity. Trigger output, environment, limits, and process supervision remain unchanged.
218
253
 
219
254
  Verifier prompts stay bounded. For an oversized Balanced review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete. Strict and Ask keep their fail-closed oversized-input behavior.
220
255
 
@@ -252,7 +287,7 @@ Set `PUM_DIR` to override the complete PUM data directory.
252
287
  | `auth.json` | Provider credentials and custom-provider keys |
253
288
  | `models.json` | Custom endpoints and model metadata; submitted keys are not stored here |
254
289
  | `settings.json` | Model and thinking level managed by pi |
255
- | `pum.json` | Theme, animation, search, writing, explanation, and check settings |
290
+ | `pum.json` | Theme, animation, search, writing, explanation, Check mode, sandbox, and subagent settings |
256
291
  | `theme.json` | Optional semantic color overrides |
257
292
  | `history.json` | Prompt history by working directory |
258
293
  | `prompt-stash.json` | Stashed prompts by working directory |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.0-beta.4",
3
+ "version": "0.2.2-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,8 @@
60
60
  "ws": "^8.21.3"
61
61
  },
62
62
  "optionalDependencies": {
63
- "@mariozechner/clipboard": "0.3.9"
63
+ "@mariozechner/clipboard": "0.3.9",
64
+ "@microsoft/mxc-sdk": "^0.7.0"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@types/bun": "^1.3.14",
package/src/app.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  checkPathsForProject,
23
23
  MAX_ACTIVE_SUBAGENTS,
24
24
  MIN_ACTIVE_SUBAGENTS,
25
+ SANDBOX_MODES,
25
26
  saveSettings,
26
27
  WORKING_RULE_ANIMATION_MODES,
27
28
  type PumSettings,
@@ -81,7 +82,7 @@ import {
81
82
  removePendingImage,
82
83
  type PendingImage,
83
84
  } from "./image-paste";
84
- import type { SubagentManager } from "./subagents/manager";
85
+ import { countActiveSubagents, type SubagentManager } from "./subagents/manager";
85
86
  import type { SpawnPreviewManager } from "./subagents/spawn-preview";
86
87
  import { SpawnPreviewPopup } from "./subagents/spawn-preview-popup";
87
88
  import { recallNewestQueuedUserMessage } from "./queue-recall";
@@ -119,6 +120,7 @@ import {
119
120
  type TriggerAction,
120
121
  type TriggerManagerLike,
121
122
  } from "./triggers/popup";
123
+ import type { TerminalTitleController } from "./terminal-title";
122
124
 
123
125
  type Stream = { kind: "assistant" | "thinking"; text: string } | null;
124
126
  type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
@@ -344,6 +346,10 @@ export function App({
344
346
  checkApprovalStore,
345
347
  triggerManager,
346
348
  messageCacheController,
349
+ terminalTitle,
350
+ startupWarnings = [],
351
+ onSandboxModeChange,
352
+ sandboxWarningSource,
347
353
  }: {
348
354
  session: AgentSession;
349
355
  modelRuntime: ModelRuntime;
@@ -365,16 +371,24 @@ export function App({
365
371
  checkApprovalStore?: CheckApprovalStore;
366
372
  triggerManager?: TriggerManagerLike;
367
373
  messageCacheController?: MessageCacheController;
374
+ terminalTitle?: TerminalTitleController;
375
+ /** Visible process-local warnings. These lines never enter pi session context. */
376
+ startupWarnings?: readonly string[];
377
+ onSandboxModeChange?: (mode: NonNullable<PumSettings["sandboxMode"]>) => void;
378
+ sandboxWarningSource?: { subscribeWarnings(listener: (warning: string) => void): () => void };
368
379
  }) {
369
380
  const cwd = process.cwd();
370
381
  const [session, setSession] = useState(initialSession);
371
382
  const [tx, setTx] = useState<Transcript>(() => ({
372
383
  // A resumed session already holds messages; show them instead of a blank pane.
373
- lines: replayEntries(
374
- initialSession.sessionManager.buildContextEntries(),
375
- cwd,
376
- initial.showThinking,
377
- ),
384
+ lines: [
385
+ ...replayEntries(
386
+ initialSession.sessionManager.buildContextEntries(),
387
+ cwd,
388
+ initial.showThinking,
389
+ ),
390
+ ...startupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
391
+ ],
378
392
  stream: null,
379
393
  pending: [],
380
394
  }));
@@ -436,6 +450,7 @@ export function App({
436
450
  const syntaxStyle = useMemo(() => buildSyntaxStyle(theme), [theme]);
437
451
  const animations = settings.animations && supportsTrueColor();
438
452
  const agents = subagentManager.getAgents();
453
+ const activeSubagentCount = countActiveSubagents(agents);
439
454
  const activeAgent = activeAgentId
440
455
  ? agents.find((agent) => agent.id === activeAgentId)
441
456
  : undefined;
@@ -553,6 +568,7 @@ export function App({
553
568
  };
554
569
  // The event subscription is set up once, so it reads the toggle via a ref.
555
570
  const showThinkingRef = useRef(initial.showThinking);
571
+ const startupWarningsRef = useRef([...startupWarnings]);
556
572
  const sessionRef = useRef(session);
557
573
  sessionRef.current = session;
558
574
  const loginControllerRef = useRef<LoginController | null>(null);
@@ -794,6 +810,10 @@ export function App({
794
810
  return pending ? resolvePendingDelivery(value, pending.id) : value;
795
811
  });
796
812
 
813
+ useEffect(() => sandboxWarningSource?.subscribeWarnings((warning) => {
814
+ append({ kind: "text", role: "system", text: warning });
815
+ }), [sandboxWarningSource]);
816
+
797
817
  useEffect(() => checkApprovalCoordinator?.subscribe((request) => {
798
818
  setCheckApproval(request);
799
819
  setCheckApprovalDecision("allowOnce");
@@ -818,6 +838,13 @@ export function App({
818
838
  [subagentManager],
819
839
  );
820
840
 
841
+ useEffect(() => {
842
+ terminalTitle?.update({
843
+ working: busy || activeSubagentCount > 0,
844
+ activeSubagentCount,
845
+ });
846
+ }, [terminalTitle, busy, activeSubagentCount]);
847
+
821
848
  useEffect(() => spawnPreviewManager?.subscribe(() => {
822
849
  setSpawnPreviewRevision((revision) => revision + 1);
823
850
  }), [spawnPreviewManager]);
@@ -865,8 +892,13 @@ export function App({
865
892
  .catch((error) => append({ kind: "text", role: "error", text: String(error) }));
866
893
  setThinkingLevel(session.agent.state.thinkingLevel as ThinkingLevel);
867
894
  setModelId(session.agent.state.model.id);
895
+ const visibleStartupWarnings = startupWarningsRef.current;
896
+ startupWarningsRef.current = [];
868
897
  setTx({
869
- lines: replayEntries(session.sessionManager.buildContextEntries(), cwd, showThinkingRef.current),
898
+ lines: [
899
+ ...replayEntries(session.sessionManager.buildContextEntries(), cwd, showThinkingRef.current),
900
+ ...visibleStartupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
901
+ ],
870
902
  stream: null,
871
903
  pending: [],
872
904
  });
@@ -1029,6 +1061,7 @@ export function App({
1029
1061
  if (patch.explanationStrength !== undefined) {
1030
1062
  setExplanationStrength(patch.explanationStrength);
1031
1063
  }
1064
+ if (patch.sandboxMode !== undefined) onSandboxModeChange?.(patch.sandboxMode);
1032
1065
  if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
1033
1066
  setCheckModeConfig({
1034
1067
  profile: next.checkMode,
@@ -1519,6 +1552,12 @@ export function App({
1519
1552
  update({ checkMode: CHECK_MODE_PROFILES[(index + step + CHECK_MODE_PROFILES.length) % CHECK_MODE_PROFILES.length]! });
1520
1553
  };
1521
1554
 
1555
+ const stepSandboxMode = (step: number) => {
1556
+ const current = settings.sandboxMode ?? "auto";
1557
+ const index = SANDBOX_MODES.indexOf(current);
1558
+ update({ sandboxMode: SANDBOX_MODES[(index + step + SANDBOX_MODES.length) % SANDBOX_MODES.length]! });
1559
+ };
1560
+
1522
1561
  const rowActions: Record<SettingRowId, { step?: (n: number) => void; enter?: () => void }> = {
1523
1562
  theme: { step: stepTheme },
1524
1563
  providers: { enter: openLogin },
@@ -1528,6 +1567,7 @@ export function App({
1528
1567
  writingStyle: { step: stepWritingStyle },
1529
1568
  explanationStrength: { step: stepExplanationStrength },
1530
1569
  checkMode: { step: stepCheckMode },
1570
+ sandboxMode: { step: stepSandboxMode },
1531
1571
  checkModel: { enter: () => {
1532
1572
  setModelQuery("");
1533
1573
  setModelSearchFocused(false);
@@ -1575,6 +1615,7 @@ export function App({
1575
1615
  writingStyle: `‹ ${settings.writingStyle} ›`,
1576
1616
  explanationStrength: `‹ ${settings.explanationStrength} ›`,
1577
1617
  checkMode: `‹ ${settings.checkMode} ›`,
1618
+ sandboxMode: `‹ ${settings.sandboxMode ?? "auto"} ›`,
1578
1619
  checkModel: `${settings.checkModel} ›`,
1579
1620
  checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
1580
1621
  clearCheckApprovals: "clear ›",
@@ -2236,7 +2277,7 @@ export function App({
2236
2277
  busy={visibleBusy}
2237
2278
  elapsedSec={visibleElapsedSec}
2238
2279
  agentCount={agents.length}
2239
- runningAgentCount={agents.filter((agent) => agent.status === "running" || agent.status === "starting").length}
2280
+ runningAgentCount={activeSubagentCount}
2240
2281
  maxActiveAgentCount={settings.maxActiveSubagents}
2241
2282
  activeAgentName={activeAgent?.name}
2242
2283
  />
package/src/check-mode.ts CHANGED
@@ -262,12 +262,13 @@ export async function prepareCheck(
262
262
  cwd: input.cwd,
263
263
  projectCwd: cwd,
264
264
  allowedPaths: additionalPaths,
265
+ protectedPaths: [AGENT_DIR],
265
266
  profile,
266
267
  });
267
268
  } else {
268
269
  const command = input && typeof input === "object" ? (input as { command?: unknown }).command : undefined;
269
270
  if (typeof command !== "string") return { block: "Bash safety check requires a complete command string or process proposal" };
270
- policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths });
271
+ policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths, protectedPaths: [AGENT_DIR] });
271
272
  }
272
273
  bash = policy.analysis;
273
274
  if (!bash.complete || bash.truncated || !bash.syntaxBalanced) {
@@ -316,6 +317,7 @@ export async function prepareCheck(
316
317
  decision: policy.decision,
317
318
  reason: policy.reason,
318
319
  findings: policy.findings,
320
+ accesses: policy.accesses,
319
321
  } : undefined,
320
322
  shell: processProposal ? undefined : bash,
321
323
  process: processProposal ? {
@@ -379,6 +381,7 @@ export async function prepareCheck(
379
381
  substitutionCount: bash.substitutions.length,
380
382
  mutationIntent: bash.mutationIntent,
381
383
  errors: bash.errors,
384
+ accesses: policy?.accesses,
382
385
  } : undefined,
383
386
  process: processProposal ? {
384
387
  source: processProposal.source,
@@ -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,11 +72,6 @@ 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
75
  async function validateEditPath(
75
76
  cwd: string,
76
77
  inputPath: string,
@@ -83,23 +84,34 @@ async function validateEditPath(
83
84
  const projectRoot = await realpath(cwd);
84
85
  const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
85
86
  const absolute = resolve(projectRoot, inputPath);
86
- const root = roots.sort((first, second) => second.length - first.length)
87
- .find((candidate) => candidate === absolute || insideRoot(candidate, absolute));
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
+ }
88
98
  if (!root) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
99
+ const canonical = await realpath(absolute);
89
100
 
90
- let component = root;
91
- const rel = relative(root, absolute);
92
- for (const part of rel.split(sep).filter(Boolean)) {
93
- component = resolve(component, part);
101
+ let component = absolute;
102
+ while (true) {
94
103
  const metadata = await lstat(component);
95
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;
96
109
  }
97
- const canonical = await realpath(absolute);
98
- 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}`);
99
111
  const metadata = await lstat(absolute);
100
112
  if (!metadata.isFile()) throw new Error(`Edit path is not a file: ${inputPath}`);
101
- const display = projectRoot === absolute || insideRoot(projectRoot, absolute)
102
- ? relative(projectRoot, absolute).split(sep).join("/")
113
+ const display = isPathInsideOrSame(projectRoot, canonical)
114
+ ? relative(projectRoot, canonical).split(sep).join("/")
103
115
  : absolute;
104
116
  return { root, absolute, display, mode: metadata.mode };
105
117
  }
@@ -1,8 +1,13 @@
1
1
  import { lstat, realpath } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { dirname, resolve } from "node:path";
4
4
  import { AGENT_DIR } from "./config";
5
5
  import { isCredentialSensitivePath } from "./check-policy";
6
+ import {
7
+ canonicalPathIdentityAllowMissing,
8
+ isPathInsideOrSame,
9
+ pathIdentity,
10
+ } from "./platform";
6
11
  import {
7
12
  checkPathsForProject,
8
13
  MAX_CHECK_PATHS_PER_PROJECT,
@@ -39,11 +44,6 @@ export function parseCheckPathCommand(input: string): CheckPathCommand | undefin
39
44
  return { action: action[1] as "add" | "remove", path };
40
45
  }
41
46
 
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
47
  async function canonicalDirectory(input: string, cwd: string): Promise<string> {
48
48
  const absolute = resolve(cwd, input);
49
49
  const canonical = await realpath(absolute);
@@ -57,10 +57,10 @@ async function canonicalDirectory(input: string, cwd: string): Promise<string> {
57
57
  realpath(AGENT_DIR).catch(() => resolve(AGENT_DIR)),
58
58
  realpath(homedir()).catch(() => resolve(homedir())),
59
59
  ]);
60
- if (insideOrSame(canonical, agentDirectory) || insideOrSame(agentDirectory, canonical)) {
60
+ if (isPathInsideOrSame(canonical, agentDirectory) || isPathInsideOrSame(agentDirectory, canonical)) {
61
61
  throw new Error("Check path cannot contain or enter PUM's configuration directory");
62
62
  }
63
- if (insideOrSame(canonical, homeDirectory)) {
63
+ if (isPathInsideOrSame(canonical, homeDirectory)) {
64
64
  throw new Error("Check path cannot contain the home directory");
65
65
  }
66
66
  return canonical;
@@ -68,7 +68,7 @@ async function canonicalDirectory(input: string, cwd: string): Promise<string> {
68
68
 
69
69
  async function removalIdentity(input: string, cwd: string): Promise<string> {
70
70
  const absolute = resolve(cwd, input);
71
- return realpath(absolute).catch(() => absolute);
71
+ return canonicalPathIdentityAllowMissing(absolute);
72
72
  }
73
73
 
74
74
  export async function applyCheckPathCommand(
@@ -102,10 +102,13 @@ export async function applyCheckPathCommand(
102
102
  : await removalIdentity(command.path, cwd);
103
103
  const project = await realpath(cwd);
104
104
  if (command.action === "add") {
105
- if (canonical === project || insideOrSame(project, canonical)) {
105
+ if (isPathInsideOrSame(project, canonical)) {
106
106
  throw new Error("The directory is already inside the project boundary");
107
107
  }
108
- if (paths.includes(canonical)) throw new Error(`Check path is already allowed: ${canonical}`);
108
+ const identity = pathIdentity(canonical);
109
+ if (paths.some((path) => pathIdentity(path) === identity)) {
110
+ throw new Error(`Check path is already allowed: ${canonical}`);
111
+ }
109
112
  if (paths.length >= MAX_CHECK_PATHS_PER_PROJECT) {
110
113
  throw new Error(`Check mode allows at most ${MAX_CHECK_PATHS_PER_PROJECT} additional paths per project`);
111
114
  }
@@ -117,7 +120,8 @@ export async function applyCheckPathCommand(
117
120
  };
118
121
  }
119
122
 
120
- const index = paths.indexOf(canonical);
123
+ const identity = pathIdentity(canonical);
124
+ const index = paths.findIndex((path) => pathIdentity(path) === identity);
121
125
  if (index < 0) throw new Error(`Check path is not configured: ${canonical}`);
122
126
  const nextPaths = paths.filter((_path, candidate) => candidate !== index);
123
127
  return {