@cruxy/cli 0.22.0 → 0.22.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/dist/approval/classify.js +7 -3
- package/dist/approval/policy.d.ts +6 -0
- package/dist/approval/policy.js +15 -3
- package/dist/approval/types.d.ts +8 -1
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/set.d.ts +44 -0
- package/dist/checkpoint/set.js +142 -0
- package/dist/checkpoint/types.d.ts +47 -0
- package/dist/errors/constructors.d.ts +37 -0
- package/dist/errors/constructors.js +109 -0
- package/dist/errors/types.d.ts +26 -0
- package/dist/errors/types.js +38 -0
- package/dist/sandbox/docker-runtime.js +4 -1
- package/dist/sandbox/policy.d.ts +12 -3
- package/dist/sandbox/policy.js +17 -3
- package/dist/sandbox/types.d.ts +10 -1
- package/dist/tools/file/paths.d.ts +10 -17
- package/dist/tools/file/paths.js +11 -58
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/resolve.d.ts +54 -0
- package/dist/workspace/resolve.js +96 -0
- package/dist/workspace/select.d.ts +41 -0
- package/dist/workspace/select.js +44 -0
- package/dist/workspace/types.d.ts +30 -0
- package/dist/workspace/types.js +15 -0
- package/dist/workspace/workspace.d.ts +56 -0
- package/dist/workspace/workspace.js +180 -0
- package/package.json +1 -1
|
@@ -62,7 +62,7 @@ function shellRequest(action, root) {
|
|
|
62
62
|
// scope is the program token. Complex commands get `none` (approve-once only).
|
|
63
63
|
const tokens = commandTokens(command);
|
|
64
64
|
const scope = tokens
|
|
65
|
-
? { kind: "shell-prefix", token: tokens[0] }
|
|
65
|
+
? { kind: "shell-prefix", token: tokens[0], root }
|
|
66
66
|
: { kind: "none" };
|
|
67
67
|
return {
|
|
68
68
|
action,
|
|
@@ -86,7 +86,9 @@ function testRequest(action, root) {
|
|
|
86
86
|
return {
|
|
87
87
|
action,
|
|
88
88
|
tier: "destructive",
|
|
89
|
-
scope: command === ""
|
|
89
|
+
scope: command === ""
|
|
90
|
+
? { kind: "none" }
|
|
91
|
+
: { kind: "shell-exact", command, root },
|
|
90
92
|
summary: `run tests: ${command}`,
|
|
91
93
|
targets: [],
|
|
92
94
|
cwd: root,
|
|
@@ -150,7 +152,9 @@ function mcpRequest(action, root) {
|
|
|
150
152
|
return {
|
|
151
153
|
action,
|
|
152
154
|
tier: "destructive",
|
|
153
|
-
scope: grantable
|
|
155
|
+
scope: grantable
|
|
156
|
+
? { kind: "mcp-tool", server, tool, root }
|
|
157
|
+
: { kind: "none" },
|
|
154
158
|
summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
|
|
155
159
|
targets: [],
|
|
156
160
|
cwd: root,
|
|
@@ -20,6 +20,12 @@ export declare class SessionAllowlist {
|
|
|
20
20
|
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
21
21
|
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
22
22
|
* resolve inside the granted subtree.
|
|
23
|
+
*
|
|
24
|
+
* Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
|
|
25
|
+
* additionally **bound to the root they were taken in** — the grant only covers a
|
|
26
|
+
* request whose `cwd` is the same root. So "allow `git` this session" in repo A
|
|
27
|
+
* never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
|
|
28
|
+
* an absolute path, so a different root is already a different subtree.
|
|
23
29
|
*/
|
|
24
30
|
export declare function scopeCovers(scope: Exclude<Scope, {
|
|
25
31
|
kind: "none";
|
package/dist/approval/policy.js
CHANGED
|
@@ -30,28 +30,40 @@ export class SessionAllowlist {
|
|
|
30
30
|
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
31
31
|
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
32
32
|
* resolve inside the granted subtree.
|
|
33
|
+
*
|
|
34
|
+
* Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
|
|
35
|
+
* additionally **bound to the root they were taken in** — the grant only covers a
|
|
36
|
+
* request whose `cwd` is the same root. So "allow `git` this session" in repo A
|
|
37
|
+
* never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
|
|
38
|
+
* an absolute path, so a different root is already a different subtree.
|
|
33
39
|
*/
|
|
34
40
|
export function scopeCovers(scope, request) {
|
|
35
41
|
if (scope.kind === "shell-prefix") {
|
|
36
42
|
if (request.action.kind !== "shell")
|
|
37
43
|
return false;
|
|
44
|
+
if (scope.root !== request.cwd)
|
|
45
|
+
return false; // C.26: same root only
|
|
38
46
|
const tokens = commandTokens(request.action.command ?? "");
|
|
39
47
|
return tokens !== null && tokens[0] === scope.token;
|
|
40
48
|
}
|
|
41
49
|
if (scope.kind === "shell-exact") {
|
|
42
50
|
// Test grants (C.13): the exact command string, test actions only — a
|
|
43
|
-
// grant for `pnpm test` can never cover run_command or any other command
|
|
51
|
+
// grant for `pnpm test` can never cover run_command or any other command,
|
|
52
|
+
// and (C.26) never a test in a different root.
|
|
44
53
|
return (request.action.kind === "test" &&
|
|
54
|
+
scope.root === request.cwd &&
|
|
45
55
|
(request.action.command ?? "").trim() === scope.command);
|
|
46
56
|
}
|
|
47
57
|
if (scope.kind === "mcp-tool") {
|
|
48
58
|
// MCP grants (C.27): the exact server+tool pair, mcp actions only — a grant
|
|
49
|
-
// for one server's tool can never cover another tool
|
|
59
|
+
// for one server's tool can never cover another tool, another server, or
|
|
60
|
+
// (C.26) the same tool invoked from a different root.
|
|
50
61
|
return (request.action.kind === "mcp" &&
|
|
62
|
+
scope.root === request.cwd &&
|
|
51
63
|
request.action.server === scope.server &&
|
|
52
64
|
request.action.tool === scope.tool);
|
|
53
65
|
}
|
|
54
|
-
// file-subtree
|
|
66
|
+
// file-subtree — absolute path, inherently root-scoped.
|
|
55
67
|
return (request.targets.length > 0 &&
|
|
56
68
|
request.targets.every((t) => isInside(scope.root, t)));
|
|
57
69
|
}
|
package/dist/approval/types.d.ts
CHANGED
|
@@ -17,20 +17,26 @@ export type RiskTier = "read" | "mutate" | "destructive";
|
|
|
17
17
|
* The tight scope a session grant is keyed by. Never blanket.
|
|
18
18
|
* - `shell-prefix` — a command's leading program token (e.g. `git`); only ever
|
|
19
19
|
* matches commands we can *positively* prove are simple (no shell features).
|
|
20
|
+
* **Bound to `root`** (C.26): a `git` grant in repo A never covers `git` in B.
|
|
20
21
|
* - `shell-exact` — one exact command string, for `test` actions only (C.13):
|
|
21
22
|
* a grant covers re-runs of precisely that test command, nothing else.
|
|
23
|
+
* **Bound to `root`**: a `pnpm test` grant in A never covers B.
|
|
22
24
|
* - `file-subtree` — an absolute directory (or, under the root-cap, an exact
|
|
23
|
-
* file path); matches targets that resolve inside it.
|
|
25
|
+
* file path); matches targets that resolve inside it. Inherently root-safe —
|
|
26
|
+
* different roots are different absolute subtrees, so it needs no `root` field.
|
|
24
27
|
* - `mcp-tool` — one exact MCP server+tool pair (C.27): a grant covers re-calls
|
|
25
28
|
* of precisely that tool on that server, and never any other MCP tool.
|
|
29
|
+
* **Bound to `root`**: the same server+tool in another root still prompts.
|
|
26
30
|
* - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
|
|
27
31
|
*/
|
|
28
32
|
export type Scope = {
|
|
29
33
|
readonly kind: "shell-prefix";
|
|
30
34
|
readonly token: string;
|
|
35
|
+
readonly root: string;
|
|
31
36
|
} | {
|
|
32
37
|
readonly kind: "shell-exact";
|
|
33
38
|
readonly command: string;
|
|
39
|
+
readonly root: string;
|
|
34
40
|
} | {
|
|
35
41
|
readonly kind: "file-subtree";
|
|
36
42
|
readonly root: string;
|
|
@@ -38,6 +44,7 @@ export type Scope = {
|
|
|
38
44
|
readonly kind: "mcp-tool";
|
|
39
45
|
readonly server: string;
|
|
40
46
|
readonly tool: string;
|
|
47
|
+
readonly root: string;
|
|
41
48
|
} | {
|
|
42
49
|
readonly kind: "none";
|
|
43
50
|
};
|
package/dist/checkpoint/index.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CheckpointSet, RollbackApplied, SetRollbackApplied } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-root rollback sets (C.26). A run that mutates N repos produces one
|
|
4
|
+
* per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
|
|
5
|
+
* `cruxy rollback` restores exactly the touched roots as one gated operation.
|
|
6
|
+
*
|
|
7
|
+
* Two guarantees live here:
|
|
8
|
+
* • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
|
|
9
|
+
* an untouched root (absent from the set) is never opened;
|
|
10
|
+
* • **stop-and-report, never silent partial** (R3) — validate every member up
|
|
11
|
+
* front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
|
|
12
|
+
* sequentially and STOP on the first failure, throwing
|
|
13
|
+
* `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
|
|
14
|
+
*/
|
|
15
|
+
/** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
16
|
+
export declare function newRunId(): string;
|
|
17
|
+
/** The set-manifest directory under the PRIMARY root (⚖︎#7). */
|
|
18
|
+
export declare function setDir(primaryRoot: string): string;
|
|
19
|
+
/** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
|
|
20
|
+
export declare function writeSet(primaryRoot: string, set: CheckpointSet): Promise<void>;
|
|
21
|
+
/** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
|
|
22
|
+
export declare function readSet(primaryRoot: string, runId: string): Promise<CheckpointSet>;
|
|
23
|
+
/** List all set manifests under the primary root, newest first. */
|
|
24
|
+
export declare function listSets(primaryRoot: string): Promise<CheckpointSet[]>;
|
|
25
|
+
/**
|
|
26
|
+
* One root's rollback, pre-validated (its plan already computed): `restore`
|
|
27
|
+
* actually applies it. The orchestrator builds one per member.
|
|
28
|
+
*/
|
|
29
|
+
export interface MemberRollback {
|
|
30
|
+
rootName: string;
|
|
31
|
+
restore: () => Promise<RollbackApplied>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Apply an all-roots rollback: run each member's `restore` in order and STOP on
|
|
35
|
+
* the first failure (R3). On success, returns the restored-vs-per-root summary.
|
|
36
|
+
* On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
|
|
37
|
+
* the failure) and those not restored (the failing one + all not-yet-attempted)
|
|
38
|
+
* — so a partial rollback can never be reported as success, and re-running (which
|
|
39
|
+
* recomputes each root from disk) safely finishes the job.
|
|
40
|
+
*
|
|
41
|
+
* The applier only ever touches roots present in `rollbacks`; an untouched root
|
|
42
|
+
* (never added to the set) is structurally impossible to open here.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applySetRollback(runId: string, rollbacks: readonly MemberRollback[]): Promise<SetRollbackApplied>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkpointSetIncomplete, checkpointSetPartial, } from "../errors/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Multi-root rollback sets (C.26). A run that mutates N repos produces one
|
|
7
|
+
* per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
|
|
8
|
+
* `cruxy rollback` restores exactly the touched roots as one gated operation.
|
|
9
|
+
*
|
|
10
|
+
* Two guarantees live here:
|
|
11
|
+
* • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
|
|
12
|
+
* an untouched root (absent from the set) is never opened;
|
|
13
|
+
* • **stop-and-report, never silent partial** (R3) — validate every member up
|
|
14
|
+
* front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
|
|
15
|
+
* sequentially and STOP on the first failure, throwing
|
|
16
|
+
* `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
|
|
17
|
+
*/
|
|
18
|
+
/** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
|
|
19
|
+
export function newRunId() {
|
|
20
|
+
const stamp = new Date()
|
|
21
|
+
.toISOString()
|
|
22
|
+
.replace(/[-:]/g, "")
|
|
23
|
+
.replace(/\..+$/, "");
|
|
24
|
+
return `run-${stamp}-${randomBytes(2).toString("hex")}`;
|
|
25
|
+
}
|
|
26
|
+
/** The set-manifest directory under the PRIMARY root (⚖︎#7). */
|
|
27
|
+
export function setDir(primaryRoot) {
|
|
28
|
+
return path.join(primaryRoot, ".cruxy", "checkpoints", "sets");
|
|
29
|
+
}
|
|
30
|
+
/** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
|
|
31
|
+
export async function writeSet(primaryRoot, set) {
|
|
32
|
+
const dir = setDir(primaryRoot);
|
|
33
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
34
|
+
// The parent checkpoints/ dir already carries a `*` .gitignore; add one here
|
|
35
|
+
// too so a set manifest is never seen by git even if the layout changes.
|
|
36
|
+
const ignoreFile = path.join(dir, ".gitignore");
|
|
37
|
+
try {
|
|
38
|
+
await fsp.access(ignoreFile);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
await fsp.writeFile(ignoreFile, "*\n");
|
|
42
|
+
}
|
|
43
|
+
const file = path.join(dir, `${set.runId}.json`);
|
|
44
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
45
|
+
await fsp.writeFile(tmp, `${JSON.stringify(set, null, 2)}\n`);
|
|
46
|
+
await fsp.rename(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
/** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
|
|
49
|
+
export async function readSet(primaryRoot, runId) {
|
|
50
|
+
const file = path.join(setDir(primaryRoot), `${runId}.json`);
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = await fsp.readFile(file, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw checkpointSetIncomplete(runId, `the set manifest is missing or unreadable (${file})`);
|
|
57
|
+
}
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw checkpointSetIncomplete(runId, "the set manifest is not valid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (!isSetShape(parsed)) {
|
|
66
|
+
throw checkpointSetIncomplete(runId, "the set manifest is malformed");
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
/** List all set manifests under the primary root, newest first. */
|
|
71
|
+
export async function listSets(primaryRoot) {
|
|
72
|
+
const dir = setDir(primaryRoot);
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = await fsp.readdir(dir);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const sets = [];
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
if (!name.endsWith(".json"))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(await fsp.readFile(path.join(dir, name), "utf8"));
|
|
86
|
+
if (isSetShape(parsed))
|
|
87
|
+
sets.push(parsed);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A corrupt individual manifest is skipped in a listing (it fails loud
|
|
91
|
+
// only when that specific run is rolled back).
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return sets.sort((a, b) => b.runId.localeCompare(a.runId));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Apply an all-roots rollback: run each member's `restore` in order and STOP on
|
|
98
|
+
* the first failure (R3). On success, returns the restored-vs-per-root summary.
|
|
99
|
+
* On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
|
|
100
|
+
* the failure) and those not restored (the failing one + all not-yet-attempted)
|
|
101
|
+
* — so a partial rollback can never be reported as success, and re-running (which
|
|
102
|
+
* recomputes each root from disk) safely finishes the job.
|
|
103
|
+
*
|
|
104
|
+
* The applier only ever touches roots present in `rollbacks`; an untouched root
|
|
105
|
+
* (never added to the set) is structurally impossible to open here.
|
|
106
|
+
*/
|
|
107
|
+
export async function applySetRollback(runId, rollbacks) {
|
|
108
|
+
const restored = [];
|
|
109
|
+
const perRoot = {};
|
|
110
|
+
for (let i = 0; i < rollbacks.length; i++) {
|
|
111
|
+
const { rootName, restore } = rollbacks[i];
|
|
112
|
+
try {
|
|
113
|
+
perRoot[rootName] = await restore();
|
|
114
|
+
restored.push(rootName);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const notRestored = rollbacks.slice(i).map((r) => r.rootName);
|
|
118
|
+
throw checkpointSetPartial(runId, restored, notRestored, err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { runId, restored, perRoot };
|
|
122
|
+
}
|
|
123
|
+
/** Structural check for a parsed set manifest — enough to fail loud on corruption. */
|
|
124
|
+
function isSetShape(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null)
|
|
126
|
+
return false;
|
|
127
|
+
const v = value;
|
|
128
|
+
if (typeof v.runId !== "string" ||
|
|
129
|
+
typeof v.createdAt !== "string" ||
|
|
130
|
+
typeof v.runSummary !== "string" ||
|
|
131
|
+
!Array.isArray(v.members)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return v.members.every((m) => {
|
|
135
|
+
if (typeof m !== "object" || m === null)
|
|
136
|
+
return false;
|
|
137
|
+
const mm = m;
|
|
138
|
+
return (typeof mm.rootName === "string" &&
|
|
139
|
+
typeof mm.rootPath === "string" &&
|
|
140
|
+
typeof mm.checkpointId === "string");
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -115,3 +115,50 @@ export interface RollbackApplied {
|
|
|
115
115
|
reverted: number;
|
|
116
116
|
deleted: number;
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* One touched root inside a {@link CheckpointSet}: the declared root's name (for
|
|
120
|
+
* attribution in the preview), its absolute path, and the id of the per-root
|
|
121
|
+
* checkpoint that protects it. There is exactly ONE member per root the run
|
|
122
|
+
* mutated — an untouched root has no member and is never opened at rollback.
|
|
123
|
+
*/
|
|
124
|
+
export interface CheckpointSetMember {
|
|
125
|
+
/** Declared workspace-root name (shown in the grouped preview). */
|
|
126
|
+
rootName: string;
|
|
127
|
+
/** Absolute path of the root (where its `.cruxy/checkpoints/` live). */
|
|
128
|
+
rootPath: string;
|
|
129
|
+
/** The checkpoint id within that root. */
|
|
130
|
+
checkpointId: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A run's rollback unit across N repos (C.26). `cruxy rollback` restores **all
|
|
134
|
+
* members** of a set as one gated operation — exactly the roots the run touched,
|
|
135
|
+
* no more (untouched roots are absent) and no less (a touched root missing its
|
|
136
|
+
* member is a loud `CRUXY_E_CHECKPOINT_SET_INCOMPLETE`, never a silent partial).
|
|
137
|
+
*
|
|
138
|
+
* The set manifest lives under the PRIMARY root's `.cruxy/checkpoints/sets/`, so
|
|
139
|
+
* it travels with the workspace and survives `rm -rf ~/.cruxy`. The member
|
|
140
|
+
* checkpoints themselves live in each root, exactly as in the single-root case.
|
|
141
|
+
*/
|
|
142
|
+
export interface CheckpointSet {
|
|
143
|
+
/** Stable run id, e.g. `run-20260708T031500-a4f2`. */
|
|
144
|
+
runId: string;
|
|
145
|
+
/** ISO-8601 creation time (of the set, i.e. the run's first mutation). */
|
|
146
|
+
createdAt: string;
|
|
147
|
+
/** One line describing the run this set protects. */
|
|
148
|
+
runSummary: string;
|
|
149
|
+
/** One entry per TOUCHED root. */
|
|
150
|
+
members: CheckpointSetMember[];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The outcome of a successful all-roots rollback: which roots were restored and
|
|
154
|
+
* what each did. On a mid-apply failure this is NOT returned — a
|
|
155
|
+
* `CRUXY_E_CHECKPOINT_SET_PARTIAL` is thrown instead, carrying the restored-vs-not
|
|
156
|
+
* split (R3), so a partial can never read as success.
|
|
157
|
+
*/
|
|
158
|
+
export interface SetRollbackApplied {
|
|
159
|
+
runId: string;
|
|
160
|
+
/** Root names restored, in apply order. */
|
|
161
|
+
restored: string[];
|
|
162
|
+
/** Per-root counts, keyed by root name. */
|
|
163
|
+
perRoot: Record<string, RollbackApplied>;
|
|
164
|
+
}
|
|
@@ -119,6 +119,43 @@ export declare function checkpointNotFound(id?: string): CruxyError;
|
|
|
119
119
|
* Restoring is destructive and deliberate — there is no auto-rollback path, ever.
|
|
120
120
|
*/
|
|
121
121
|
export declare function rollbackApprovalRequired(): CruxyError;
|
|
122
|
+
/**
|
|
123
|
+
* A path/selector names a workspace root that is not in the declared set — an
|
|
124
|
+
* unknown root name, or an absolute path that lands in no declared root. Fail-loud
|
|
125
|
+
* by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
|
|
126
|
+
* a cross-root misfire. Refused before any FS access.
|
|
127
|
+
*/
|
|
128
|
+
export declare function rootUnknown(ref: string, known: readonly string[]): CruxyError;
|
|
129
|
+
/**
|
|
130
|
+
* A path is ambiguous across the declared root set: an absolute path inside ≥2
|
|
131
|
+
* declared roots, or a mutating tool given no root when there is no unambiguous
|
|
132
|
+
* choice. Writes fail closed rather than guess which repo to touch.
|
|
133
|
+
*/
|
|
134
|
+
export declare function rootAmbiguous(ref: string, candidates: string[]): CruxyError;
|
|
135
|
+
/**
|
|
136
|
+
* Declaration-time: a declared root nests inside / overlaps another. Refused at
|
|
137
|
+
* session start — overlap makes "which root owns this path" ambiguous and lets two
|
|
138
|
+
* checkpoints/grants fight over the same bytes.
|
|
139
|
+
*/
|
|
140
|
+
export declare function rootOverlap(a: string, b: string): CruxyError;
|
|
141
|
+
/**
|
|
142
|
+
* An interactive add-root was refused: no TTY to confirm, or the user declined the
|
|
143
|
+
* confirm/trust prompt. The root set only ever grows by an explicit human act —
|
|
144
|
+
* never the model, never a repo-local config.
|
|
145
|
+
*/
|
|
146
|
+
export declare function rootAddRefused(reason: string): CruxyError;
|
|
147
|
+
/**
|
|
148
|
+
* A multi-root rollback set references a member checkpoint that is missing or
|
|
149
|
+
* corrupt, or a touched root has no member. Loud — a partial rollback must never
|
|
150
|
+
* masquerade as success.
|
|
151
|
+
*/
|
|
152
|
+
export declare function checkpointSetIncomplete(runId: string, reason: string): CruxyError;
|
|
153
|
+
/**
|
|
154
|
+
* A multi-root rollback failed mid-apply (R3): it restored some roots and not
|
|
155
|
+
* others, and it STOPPED rather than continue best-effort. Carries the exact
|
|
156
|
+
* restored-vs-not split; re-running rollback is idempotent and safe.
|
|
157
|
+
*/
|
|
158
|
+
export declare function checkpointSetPartial(runId: string, restored: string[], notRestored: string[], underlying?: unknown): CruxyError;
|
|
122
159
|
/**
|
|
123
160
|
* A subagent spawn was attempted past the configured nesting cap (C.14). The
|
|
124
161
|
* spawn tool is structurally withheld at the cap, so reaching this means the
|
|
@@ -551,6 +551,115 @@ export function rollbackApprovalRequired() {
|
|
|
551
551
|
],
|
|
552
552
|
});
|
|
553
553
|
}
|
|
554
|
+
// ── multi-repo / workspace (exit 18) — C.26 ───────────────────────────────────
|
|
555
|
+
/**
|
|
556
|
+
* A path/selector names a workspace root that is not in the declared set — an
|
|
557
|
+
* unknown root name, or an absolute path that lands in no declared root. Fail-loud
|
|
558
|
+
* by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
|
|
559
|
+
* a cross-root misfire. Refused before any FS access.
|
|
560
|
+
*/
|
|
561
|
+
export function rootUnknown(ref, known) {
|
|
562
|
+
return new CruxyError({
|
|
563
|
+
code: ErrorCode.RootUnknown,
|
|
564
|
+
title: `no workspace root named "${ref}"`,
|
|
565
|
+
cause: "the root set is fixed at session start and matched exactly — never by prefix or nearest-name",
|
|
566
|
+
nextSteps: [
|
|
567
|
+
known.length
|
|
568
|
+
? `declared roots: ${known.join(", ")}`
|
|
569
|
+
: "no additional roots are declared this session",
|
|
570
|
+
"pass --root <name>=<path> at startup, or add one interactively",
|
|
571
|
+
],
|
|
572
|
+
meta: { ref, known: [...known] },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* A path is ambiguous across the declared root set: an absolute path inside ≥2
|
|
577
|
+
* declared roots, or a mutating tool given no root when there is no unambiguous
|
|
578
|
+
* choice. Writes fail closed rather than guess which repo to touch.
|
|
579
|
+
*/
|
|
580
|
+
export function rootAmbiguous(ref, candidates) {
|
|
581
|
+
return new CruxyError({
|
|
582
|
+
code: ErrorCode.RootAmbiguous,
|
|
583
|
+
title: `"${ref}" is ambiguous across the declared roots`,
|
|
584
|
+
cause: candidates.length > 1
|
|
585
|
+
? `it resolves inside more than one declared root: ${candidates.join(", ")}`
|
|
586
|
+
: "a mutating action must name exactly one root",
|
|
587
|
+
nextSteps: [
|
|
588
|
+
"name the root explicitly with the `root` argument",
|
|
589
|
+
"declare either the monorepo root OR its packages, never both (overlap is refused)",
|
|
590
|
+
],
|
|
591
|
+
meta: { ref, candidates },
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Declaration-time: a declared root nests inside / overlaps another. Refused at
|
|
596
|
+
* session start — overlap makes "which root owns this path" ambiguous and lets two
|
|
597
|
+
* checkpoints/grants fight over the same bytes.
|
|
598
|
+
*/
|
|
599
|
+
export function rootOverlap(a, b) {
|
|
600
|
+
return new CruxyError({
|
|
601
|
+
code: ErrorCode.RootOverlap,
|
|
602
|
+
title: "declared workspace roots overlap",
|
|
603
|
+
cause: `"${a}" nests inside or equals "${b}"`,
|
|
604
|
+
nextSteps: [
|
|
605
|
+
"declare the monorepo root OR specific package roots, never both",
|
|
606
|
+
"remove one of the overlapping --root entries",
|
|
607
|
+
],
|
|
608
|
+
meta: { a, b },
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* An interactive add-root was refused: no TTY to confirm, or the user declined the
|
|
613
|
+
* confirm/trust prompt. The root set only ever grows by an explicit human act —
|
|
614
|
+
* never the model, never a repo-local config.
|
|
615
|
+
*/
|
|
616
|
+
export function rootAddRefused(reason) {
|
|
617
|
+
return new CruxyError({
|
|
618
|
+
code: ErrorCode.RootAddRefused,
|
|
619
|
+
title: "adding a workspace root was refused",
|
|
620
|
+
cause: reason,
|
|
621
|
+
nextSteps: [
|
|
622
|
+
"declare roots up front with --root at startup",
|
|
623
|
+
"add a root only from an interactive terminal, where it can be confirmed and trusted",
|
|
624
|
+
],
|
|
625
|
+
meta: { reason },
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* A multi-root rollback set references a member checkpoint that is missing or
|
|
630
|
+
* corrupt, or a touched root has no member. Loud — a partial rollback must never
|
|
631
|
+
* masquerade as success.
|
|
632
|
+
*/
|
|
633
|
+
export function checkpointSetIncomplete(runId, reason) {
|
|
634
|
+
return new CruxyError({
|
|
635
|
+
code: ErrorCode.CheckpointSetIncomplete,
|
|
636
|
+
title: `rollback set "${runId}" is incomplete`,
|
|
637
|
+
cause: reason,
|
|
638
|
+
nextSteps: [
|
|
639
|
+
"run `cruxy checkpoint list` to inspect each root's checkpoints",
|
|
640
|
+
"roll back an individual root's checkpoint with `cruxy rollback <id>` if needed",
|
|
641
|
+
],
|
|
642
|
+
meta: { runId, reason },
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* A multi-root rollback failed mid-apply (R3): it restored some roots and not
|
|
647
|
+
* others, and it STOPPED rather than continue best-effort. Carries the exact
|
|
648
|
+
* restored-vs-not split; re-running rollback is idempotent and safe.
|
|
649
|
+
*/
|
|
650
|
+
export function checkpointSetPartial(runId, restored, notRestored, underlying) {
|
|
651
|
+
return new CruxyError({
|
|
652
|
+
code: ErrorCode.CheckpointSetPartial,
|
|
653
|
+
title: `rollback of set "${runId}" stopped partway`,
|
|
654
|
+
cause: `restored: ${restored.join(", ") || "none"}; not restored: ${notRestored.join(", ") || "none"}`,
|
|
655
|
+
nextSteps: [
|
|
656
|
+
"re-run `cruxy rollback` — it recomputes each root from disk and is safe to retry",
|
|
657
|
+
"the not-restored roots are unchanged; no root is left half-applied silently",
|
|
658
|
+
],
|
|
659
|
+
underlying,
|
|
660
|
+
meta: { runId, restored, notRestored },
|
|
661
|
+
});
|
|
662
|
+
}
|
|
554
663
|
// ── subagent (exit 2 / 11) ────────────────────────────────────────────────────
|
|
555
664
|
/**
|
|
556
665
|
* A subagent spawn was attempted past the configured nesting cap (C.14). The
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -119,6 +119,32 @@ export declare const ErrorCode: {
|
|
|
119
119
|
* (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
|
|
120
120
|
* security stop, kept distinct from an ordinary fetch failure for grep-ability. */
|
|
121
121
|
readonly WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST";
|
|
122
|
+
/** A path/selector names a workspace root that is not in the declared set (or an
|
|
123
|
+
* absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
|
|
124
|
+
* prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
|
|
125
|
+
* Refused before any FS access; never falls through to the host filesystem. */
|
|
126
|
+
readonly RootUnknown: "CRUXY_E_ROOT_UNKNOWN";
|
|
127
|
+
/** A path is ambiguous across the declared root set: an absolute path that falls
|
|
128
|
+
* inside ≥2 declared roots, or a mutating tool given no root when there's no
|
|
129
|
+
* unambiguous choice. Writes fail closed rather than guess a root. */
|
|
130
|
+
readonly RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS";
|
|
131
|
+
/** Declaration-time: a declared root nests inside / overlaps another. Refused at
|
|
132
|
+
* session start — overlap makes "which root owns this path" ambiguous and lets
|
|
133
|
+
* two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
|
|
134
|
+
* its packages, never both. */
|
|
135
|
+
readonly RootOverlap: "CRUXY_E_ROOT_OVERLAP";
|
|
136
|
+
/** An interactive add-root was refused: no TTY to confirm, or the user declined
|
|
137
|
+
* the confirm/trust prompt. The root set only ever grows by an explicit human
|
|
138
|
+
* act — never by the model or a repo-local config. */
|
|
139
|
+
readonly RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED";
|
|
140
|
+
/** A multi-root rollback set references a member checkpoint that is missing or
|
|
141
|
+
* corrupt, or a touched root has no member. Loud — a partial rollback must never
|
|
142
|
+
* masquerade as success. */
|
|
143
|
+
readonly CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE";
|
|
144
|
+
/** A multi-root rollback failed mid-apply (R3): carries which roots were restored
|
|
145
|
+
* and which were not. The set is left recoverable by an idempotent re-run and is
|
|
146
|
+
* NEVER reported as success. */
|
|
147
|
+
readonly CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL";
|
|
122
148
|
};
|
|
123
149
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
124
150
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -138,6 +138,33 @@ export const ErrorCode = {
|
|
|
138
138
|
* (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
|
|
139
139
|
* security stop, kept distinct from an ordinary fetch failure for grep-ability. */
|
|
140
140
|
WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST",
|
|
141
|
+
// multi-repo / workspace (exit 18) — C.26
|
|
142
|
+
/** A path/selector names a workspace root that is not in the declared set (or an
|
|
143
|
+
* absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
|
|
144
|
+
* prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
|
|
145
|
+
* Refused before any FS access; never falls through to the host filesystem. */
|
|
146
|
+
RootUnknown: "CRUXY_E_ROOT_UNKNOWN",
|
|
147
|
+
/** A path is ambiguous across the declared root set: an absolute path that falls
|
|
148
|
+
* inside ≥2 declared roots, or a mutating tool given no root when there's no
|
|
149
|
+
* unambiguous choice. Writes fail closed rather than guess a root. */
|
|
150
|
+
RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS",
|
|
151
|
+
/** Declaration-time: a declared root nests inside / overlaps another. Refused at
|
|
152
|
+
* session start — overlap makes "which root owns this path" ambiguous and lets
|
|
153
|
+
* two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
|
|
154
|
+
* its packages, never both. */
|
|
155
|
+
RootOverlap: "CRUXY_E_ROOT_OVERLAP",
|
|
156
|
+
/** An interactive add-root was refused: no TTY to confirm, or the user declined
|
|
157
|
+
* the confirm/trust prompt. The root set only ever grows by an explicit human
|
|
158
|
+
* act — never by the model or a repo-local config. */
|
|
159
|
+
RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED",
|
|
160
|
+
/** A multi-root rollback set references a member checkpoint that is missing or
|
|
161
|
+
* corrupt, or a touched root has no member. Loud — a partial rollback must never
|
|
162
|
+
* masquerade as success. */
|
|
163
|
+
CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE",
|
|
164
|
+
/** A multi-root rollback failed mid-apply (R3): carries which roots were restored
|
|
165
|
+
* and which were not. The set is left recoverable by an idempotent re-run and is
|
|
166
|
+
* NEVER reported as success. */
|
|
167
|
+
CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL",
|
|
141
168
|
};
|
|
142
169
|
/**
|
|
143
170
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -228,6 +255,17 @@ const EXIT_CODES = {
|
|
|
228
255
|
[ErrorCode.WebSearch]: 17,
|
|
229
256
|
[ErrorCode.WebFetch]: 17,
|
|
230
257
|
[ErrorCode.WebBlockedHost]: 17,
|
|
258
|
+
// Multi-repo / workspace (C.26). Declaration-time root-set problems
|
|
259
|
+
// (unknown/ambiguous/overlap/add-refused) and multi-root rollback failures
|
|
260
|
+
// (incomplete/partial) share a greppable exit code. A cross-root *path* is NOT
|
|
261
|
+
// here — it reuses CRUXY_E_PATH_ESCAPE (a cross-root path is an escape from the
|
|
262
|
+
// acting root; a distinct code would wrongly imply "less bad").
|
|
263
|
+
[ErrorCode.RootUnknown]: 18,
|
|
264
|
+
[ErrorCode.RootAmbiguous]: 18,
|
|
265
|
+
[ErrorCode.RootOverlap]: 18,
|
|
266
|
+
[ErrorCode.RootAddRefused]: 18,
|
|
267
|
+
[ErrorCode.CheckpointSetIncomplete]: 18,
|
|
268
|
+
[ErrorCode.CheckpointSetPartial]: 18,
|
|
231
269
|
};
|
|
232
270
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
233
271
|
export function exitCodeFor(code) {
|
|
@@ -151,7 +151,10 @@ export function buildRunArgs(policy, container, command) {
|
|
|
151
151
|
"--tmpfs",
|
|
152
152
|
`${policy.tmpfs}:rw,nosuid,nodev,size=64m`, // … except an in-memory tmp
|
|
153
153
|
"-v",
|
|
154
|
-
mountSpec(policy.workdir), //
|
|
154
|
+
mountSpec(policy.workdir), // the command's own root, read-write
|
|
155
|
+
// Other declared roots (C.26, R5): read-only unless a per-command escalation
|
|
156
|
+
// flipped one to rw. Ambient cross-root write is never the default.
|
|
157
|
+
...(policy.siblingRoots ?? []).flatMap((m) => ["-v", mountSpec(m)]),
|
|
155
158
|
...policy.mounts.flatMap((m) => ["-v", mountSpec(m)]),
|
|
156
159
|
"-w",
|
|
157
160
|
policy.workdir.target,
|
package/dist/sandbox/policy.d.ts
CHANGED
|
@@ -5,8 +5,12 @@ import type { IsolationPolicy } from "./types.js";
|
|
|
5
5
|
* {@link IsolationPolicy}. This is where the security posture is decided, and
|
|
6
6
|
* every default here is deny/minimal:
|
|
7
7
|
*
|
|
8
|
-
* - the
|
|
9
|
-
* path, so paths stay coherent with the host and the C.32
|
|
8
|
+
* - the DEFAULT read-write mount is the command's own project workdir (at its
|
|
9
|
+
* identical absolute path, so paths stay coherent with the host and the C.32
|
|
10
|
+
* checkpoint);
|
|
11
|
+
* - other declared roots in a multi-repo session (C.26, R5) are mounted READ-ONLY
|
|
12
|
+
* — readable for legit cross-repo builds, never writable unless an explicit
|
|
13
|
+
* per-command escalation names that root in `writableRoots`;
|
|
10
14
|
* - extra mounts come solely from `sandbox.mounts` (explicit by construction),
|
|
11
15
|
* and a mount of the docker socket, the cruxy home, or the user's home root
|
|
12
16
|
* is rejected — those are the escape hatches we refuse to open;
|
|
@@ -14,4 +18,9 @@ import type { IsolationPolicy } from "./types.js";
|
|
|
14
18
|
* writable and never left root-owned;
|
|
15
19
|
* - network defaults to `none`; any widening can only come from explicit config.
|
|
16
20
|
*/
|
|
17
|
-
export declare function buildPolicy(cfg: SandboxConfig, cwd: string
|
|
21
|
+
export declare function buildPolicy(cfg: SandboxConfig, cwd: string, opts?: {
|
|
22
|
+
/** Absolute paths of the OTHER declared roots (this command's siblings). */
|
|
23
|
+
siblingRoots?: readonly string[];
|
|
24
|
+
/** Sibling roots that got an approved cross-root-write escalation (R5). */
|
|
25
|
+
writableRoots?: readonly string[];
|
|
26
|
+
}): IsolationPolicy;
|