pum-agent 0.2.0-beta.4 → 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.4",
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",
@@ -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 {
@@ -617,11 +617,16 @@ function additionalRootProblem(
617
617
  ): "missing" | "link" | undefined {
618
618
  try {
619
619
  if (!fs.exists(root)) return "missing";
620
- if (fs.isSymbolicLink(root)) return "link";
621
620
  const flavor = pathFlavor(root, root);
622
- return flavor.relative(flavor.resolve(root), flavor.resolve(fs.realpath(root))) === ""
623
- ? undefined
624
- : "link";
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;
625
630
  } catch {
626
631
  return "link";
627
632
  }
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,