@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.
@@ -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 === "" ? { kind: "none" } : { kind: "shell-exact", 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 ? { kind: "mcp-tool", server, tool } : { kind: "none" },
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";
@@ -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 or another server.
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
  }
@@ -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
  };
@@ -4,3 +4,4 @@ export * from "./git-store.js";
4
4
  export * from "./shadow-store.js";
5
5
  export * from "./restore.js";
6
6
  export * from "./service.js";
7
+ export * from "./set.js";
@@ -4,3 +4,4 @@ export * from "./git-store.js";
4
4
  export * from "./shadow-store.js";
5
5
  export * from "./restore.js";
6
6
  export * from "./service.js";
7
+ export * from "./set.js";
@@ -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
@@ -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). */
@@ -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), // ONLY the workdir, read-write
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,
@@ -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 ONLY read-write mount is the project workdir (at its identical absolute
9
- * path, so paths stay coherent with the host and the C.32 checkpoint);
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): IsolationPolicy;
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;