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 +3 -1
- package/package.json +1 -1
- package/src/check-mutation.ts +28 -16
- package/src/check-paths.ts +16 -12
- package/src/check-policy.ts +9 -4
- package/src/platform.ts +39 -0
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
package/src/check-mutation.ts
CHANGED
|
@@ -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,
|
|
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
|
|
87
|
-
|
|
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 =
|
|
91
|
-
|
|
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
|
-
|
|
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 =
|
|
102
|
-
? relative(projectRoot,
|
|
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
|
}
|
package/src/check-paths.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { lstat, realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname,
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
105
|
+
if (isPathInsideOrSame(project, canonical)) {
|
|
106
106
|
throw new Error("The directory is already inside the project boundary");
|
|
107
107
|
}
|
|
108
|
-
|
|
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
|
|
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 {
|
package/src/check-policy.ts
CHANGED
|
@@ -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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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,
|