@cruxy/cli 0.22.0 → 0.23.0

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.
Files changed (80) hide show
  1. package/dist/approval/classify.js +25 -3
  2. package/dist/approval/policy.d.ts +6 -0
  3. package/dist/approval/policy.js +15 -3
  4. package/dist/approval/prompt.js +11 -0
  5. package/dist/approval/types.d.ts +8 -1
  6. package/dist/checkpoint/gate.d.ts +65 -0
  7. package/dist/checkpoint/gate.js +86 -0
  8. package/dist/checkpoint/index.d.ts +3 -0
  9. package/dist/checkpoint/index.js +3 -0
  10. package/dist/checkpoint/set-rollback.d.ts +51 -0
  11. package/dist/checkpoint/set-rollback.js +74 -0
  12. package/dist/checkpoint/set.d.ts +44 -0
  13. package/dist/checkpoint/set.js +142 -0
  14. package/dist/checkpoint/types.d.ts +47 -0
  15. package/dist/cli/commands/rollback.d.ts +11 -6
  16. package/dist/cli/commands/rollback.js +93 -33
  17. package/dist/cli/commands/run.js +59 -10
  18. package/dist/cli/onboard.js +4 -1
  19. package/dist/cli/repl.d.ts +2 -2
  20. package/dist/cli/session-factory.d.ts +4 -3
  21. package/dist/cli/session-factory.js +98 -12
  22. package/dist/errors/constructors.d.ts +65 -0
  23. package/dist/errors/constructors.js +168 -0
  24. package/dist/errors/types.d.ts +46 -0
  25. package/dist/errors/types.js +64 -0
  26. package/dist/indexing/retriever.d.ts +29 -0
  27. package/dist/indexing/retriever.js +26 -0
  28. package/dist/indexing/service.js +3 -1
  29. package/dist/indexing/types.d.ts +7 -0
  30. package/dist/lsp/tools/common.d.ts +34 -7
  31. package/dist/lsp/tools/common.js +33 -11
  32. package/dist/lsp/tools/find-definition.js +2 -2
  33. package/dist/lsp/tools/find-references.js +10 -4
  34. package/dist/lsp/tools/get-diagnostics.js +6 -4
  35. package/dist/render/diff.js +42 -5
  36. package/dist/sandbox/docker-runtime.js +4 -1
  37. package/dist/sandbox/policy.d.ts +12 -3
  38. package/dist/sandbox/policy.js +17 -3
  39. package/dist/sandbox/types.d.ts +10 -1
  40. package/dist/subagent/orchestrator.d.ts +15 -0
  41. package/dist/subagent/orchestrator.js +2 -0
  42. package/dist/testing/run-tests-tool.js +3 -0
  43. package/dist/tools/create-pull-request.d.ts +3 -0
  44. package/dist/tools/create-pull-request.js +50 -4
  45. package/dist/tools/file/apply-patch.js +2 -2
  46. package/dist/tools/file/edit-file.js +2 -2
  47. package/dist/tools/file/glob.d.ts +9 -2
  48. package/dist/tools/file/glob.js +73 -19
  49. package/dist/tools/file/grep-files.d.ts +12 -2
  50. package/dist/tools/file/grep-files.js +113 -38
  51. package/dist/tools/file/paths.d.ts +123 -17
  52. package/dist/tools/file/paths.js +158 -50
  53. package/dist/tools/file/read-file.js +2 -2
  54. package/dist/tools/file/write-file.js +2 -2
  55. package/dist/tools/git-status.d.ts +8 -1
  56. package/dist/tools/git-status.js +43 -11
  57. package/dist/tools/list-files.d.ts +9 -3
  58. package/dist/tools/list-files.js +48 -13
  59. package/dist/tools/search-codebase.d.ts +10 -0
  60. package/dist/tools/search-codebase.js +117 -14
  61. package/dist/tools/shell/exec.js +8 -1
  62. package/dist/tools/types.d.ts +63 -1
  63. package/dist/vcs/git.d.ts +8 -0
  64. package/dist/vcs/git.js +14 -0
  65. package/dist/vcs/github.d.ts +7 -1
  66. package/dist/vcs/github.js +10 -1
  67. package/dist/vcs/service.d.ts +8 -0
  68. package/dist/vcs/service.js +33 -1
  69. package/dist/vcs/types.d.ts +18 -2
  70. package/dist/workspace/index.d.ts +5 -0
  71. package/dist/workspace/index.js +3 -0
  72. package/dist/workspace/resolve.d.ts +54 -0
  73. package/dist/workspace/resolve.js +96 -0
  74. package/dist/workspace/select.d.ts +41 -0
  75. package/dist/workspace/select.js +44 -0
  76. package/dist/workspace/types.d.ts +30 -0
  77. package/dist/workspace/types.js +15 -0
  78. package/dist/workspace/workspace.d.ts +56 -0
  79. package/dist/workspace/workspace.js +180 -0
  80. package/package.json +1 -1
@@ -2,7 +2,8 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { glob } from "tinyglobby";
4
4
  import { z } from "zod";
5
- import { resolveInRoot } from "./paths.js";
5
+ import { confineToRoot } from "../../workspace/index.js";
6
+ import { contextWorkspace, isEscapingPattern, labelPath, resolveToolPath, } from "./paths.js";
6
7
  /** Default cap on returned match lines; beyond it we report the overflow. */
7
8
  const DEFAULT_MAX_RESULTS = 100;
8
9
  /** Trim point for a single matched line so long lines don't flood the output. */
@@ -32,15 +33,31 @@ const parameters = z.object({
32
33
  .positive()
33
34
  .optional()
34
35
  .describe(`Maximum number of matching lines to return (default ${DEFAULT_MAX_RESULTS}).`),
36
+ root: z
37
+ .string()
38
+ .optional()
39
+ .describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root (a relative `path` is applied under each) and label each match with its root."),
35
40
  });
41
+ /** True when `p`'s first segment names a declared root (a `‹root›/rest` selector). */
42
+ function firstSegmentIsRoot(ws, p) {
43
+ const seg = p.split(/[/\\]/, 1)[0];
44
+ return seg.length > 0 && seg !== p && ws.tryRootByName(seg) !== undefined;
45
+ }
36
46
  /**
37
- * Search file *contents* for a regex within the project root. Read-only — no
47
+ * Search file *contents* for a regex within the workspace. Read-only — no
38
48
  * approval — so the model should prefer this over shelling out to grep/rg via
39
49
  * run_command (which is platform-dependent and routes through the approval gate).
40
50
  *
41
51
  * Files are enumerated with the same glob mechanism as the `glob` tool (so
42
52
  * node_modules and .git are always ignored), binary files are skipped, and the
43
- * search is bounded by the project-root path boundary shared by every file tool.
53
+ * search is bounded by the same root boundary as every file tool.
54
+ *
55
+ * Multi-repo (C.26, Funnel B): with more than one declared root and no root-
56
+ * selecting argument, the search FANS every root — a bare relative `path` is
57
+ * applied under each root (⚖︎JC-6) — and labels each match `‹root› ▸ path`. A
58
+ * `root` argument, or an absolute / `‹root›/…`-prefixed `path`, scopes it to that
59
+ * one root. Each walk starts at exactly one confined root directory, so a match
60
+ * can only come from the root its label names.
44
61
  */
45
62
  export const grepFilesTool = {
46
63
  name: "grep_files",
@@ -55,59 +72,117 @@ export const grepFilesTool = {
55
72
  catch (err) {
56
73
  return { ok: false, error: `invalid regex: ${err.message}` };
57
74
  }
58
- const root = path.resolve(ctx.cwd);
59
- let scope;
75
+ // The `glob` filter is not a resolvable path, so — like the glob tool — it is
76
+ // confined by rejecting a pattern that could walk outside the scope (G2).
77
+ if (input.glob && isEscapingPattern(input.glob)) {
78
+ return {
79
+ ok: false,
80
+ error: "glob must be relative to the search scope (no '..' or absolute paths)",
81
+ };
82
+ }
83
+ const ws = contextWorkspace(ctx);
84
+ let targets;
60
85
  try {
61
- scope = input.path ? await resolveInRoot(ctx, input.path) : root;
86
+ if (input.root || !ws.isMultiRoot) {
87
+ // Explicit named root, or a single-root session: confine the path within
88
+ // that one root exactly as the pre-C.26 tool did (byte-identical output).
89
+ const { root, abs } = await resolveToolPath(ctx, {
90
+ root: input.root,
91
+ path: input.path ?? ".",
92
+ });
93
+ targets = [{ root, scope: abs }];
94
+ }
95
+ else if (input.path &&
96
+ (path.isAbsolute(input.path) || firstSegmentIsRoot(ws, input.path))) {
97
+ // A `path` that itself points into / names one root selects that root.
98
+ const { root, abs } = await resolveToolPath(ctx, { path: input.path });
99
+ targets = [{ root, scope: abs }];
100
+ }
101
+ else {
102
+ // Fan: apply the bare-relative `path` (or none) under EACH root (⚖︎JC-6).
103
+ targets = await Promise.all(ws.roots().map(async (root) => ({
104
+ root,
105
+ scope: input.path
106
+ ? await confineToRoot(root.absPath, input.path)
107
+ : root.absPath,
108
+ })));
109
+ }
62
110
  }
63
111
  catch (err) {
64
112
  return { ok: false, error: err.message };
65
113
  }
66
114
  const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
67
115
  try {
68
- const files = await glob(input.glob ?? "**/*", {
69
- cwd: scope,
70
- ignore: DEFAULT_IGNORE,
71
- onlyFiles: true,
72
- dot: false,
73
- });
74
- files.sort();
75
- const lines = [];
116
+ const rows = []; // shown (capped) lines
76
117
  let total = 0; // total matches found, including those past the cap
77
- for (const rel of files) {
78
- const abs = path.join(scope, rel);
79
- const buf = await fs.readFile(abs);
80
- if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
81
- continue; // binary
82
- const display = path.relative(root, abs);
83
- const fileLines = buf.toString("utf8").split("\n");
84
- for (let i = 0; i < fileLines.length; i++) {
85
- if (!regex.test(fileLines[i]))
86
- continue;
87
- total++;
88
- if (lines.length < maxResults) {
89
- let text = fileLines[i].trim();
90
- if (text.length > MAX_LINE_LENGTH) {
91
- text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
118
+ const totalByRoot = new Map();
119
+ for (const target of targets) {
120
+ const files = await glob(input.glob ?? "**/*", {
121
+ cwd: target.scope,
122
+ ignore: DEFAULT_IGNORE,
123
+ onlyFiles: true,
124
+ dot: false,
125
+ });
126
+ files.sort();
127
+ for (const rel of files) {
128
+ const abs = path.join(target.scope, rel);
129
+ const buf = await fs.readFile(abs);
130
+ if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
131
+ continue; // binary
132
+ const display = labelPath(target.root, path.relative(target.root.absPath, abs), ws.isMultiRoot);
133
+ const fileLines = buf.toString("utf8").split("\n");
134
+ for (let i = 0; i < fileLines.length; i++) {
135
+ if (!regex.test(fileLines[i]))
136
+ continue;
137
+ total++;
138
+ totalByRoot.set(target.root.name, (totalByRoot.get(target.root.name) ?? 0) + 1);
139
+ if (rows.length < maxResults) {
140
+ let text = fileLines[i].trim();
141
+ if (text.length > MAX_LINE_LENGTH) {
142
+ text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
143
+ }
144
+ rows.push({
145
+ root: target.root.name,
146
+ line: `${display}:${i + 1}: ${text}`,
147
+ });
92
148
  }
93
- lines.push(`${display}:${i + 1}: ${text}`);
94
149
  }
95
150
  }
96
151
  }
97
152
  if (total === 0) {
98
153
  return { ok: true, output: "(no matches)" };
99
154
  }
100
- const omitted = total - lines.length;
101
- const body = lines.join("\n");
102
- return {
103
- ok: true,
104
- output: omitted > 0
105
- ? `${body}\n\n[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`
106
- : body,
107
- };
155
+ const omitted = total - rows.length;
156
+ const body = rows.map((r) => r.line).join("\n");
157
+ if (omitted === 0) {
158
+ return { ok: true, output: body };
159
+ }
160
+ const footer = ws.isMultiRoot
161
+ ? grepTruncationFooter(omitted, rows, totalByRoot)
162
+ : `[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`;
163
+ return { ok: true, output: `${body}\n\n${footer}` };
108
164
  }
109
165
  catch (err) {
110
166
  return { ok: false, error: err.message };
111
167
  }
112
168
  },
113
169
  };
170
+ /**
171
+ * Truncation footer for a fanned grep: the overall overflow count plus a per-root
172
+ * note for every root whose matches the cap dropped — so a root cut off by the cap
173
+ * is named, never silently read as having no matches.
174
+ */
175
+ function grepTruncationFooter(omitted, shownRows, totalByRoot) {
176
+ const shownByRoot = new Map();
177
+ for (const r of shownRows) {
178
+ shownByRoot.set(r.root, (shownByRoot.get(r.root) ?? 0) + 1);
179
+ }
180
+ const lines = [`[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`];
181
+ for (const [name, count] of totalByRoot) {
182
+ const dropped = count - (shownByRoot.get(name) ?? 0);
183
+ if (dropped > 0) {
184
+ lines.push(`— ${name}: ${dropped} more omitted`);
185
+ }
186
+ }
187
+ return lines.join("\n");
188
+ }
@@ -1,26 +1,132 @@
1
- import { CruxyError } from "../../errors/index.js";
1
+ import { PathEscapeError, type DeclaredRoot, type RootRef, type Workspace } from "../../workspace/index.js";
2
2
  import type { ToolContext } from "../types.js";
3
3
  /**
4
- * Thrown when a tool argument resolves to a path outside the project root —
5
- * whether via `../` traversal, an absolute path, or a symlink pointing outward.
6
- * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
7
- * reaches the boundary; tools still catch it and surface `{ ok:false }`.
4
+ * Path confinement for file tools (C.26). The confinement kernel itself lives in
5
+ * `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
6
+ * that every path-taking tool calls instead of touching the filesystem directly.
7
+ * Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
8
+ * outside the selected root") a structural property rather than a per-tool habit.
8
9
  */
9
- export declare class PathEscapeError extends CruxyError {
10
- constructor(message: string);
11
- }
10
+ export { PathEscapeError };
12
11
  /**
13
- * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
14
- * stays inside the single security boundary every file tool funnels through.
12
+ * The workspace this context acts in. Runtime always sets `ctx.workspace`; the
13
+ * fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
14
+ * many test contexts (and any not-yet-migrated caller) that don't set one. The
15
+ * fallback is byte-identical to the legacy single-root behaviour by construction:
16
+ * its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
17
+ * `resolveInRoot` confined to.
18
+ */
19
+ export declare function contextWorkspace(ctx: ToolContext): Workspace;
20
+ /**
21
+ * Resolve a tool-supplied path against the workspace and prove it stays inside the
22
+ * one root it selects — the single funnel for every path-taking file tool.
23
+ *
24
+ * Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
25
+ * which is exactly the legacy `resolveInRoot` behaviour (same result, same
26
+ * error type + message for every case: relative, absolute-inside, absolute-outside,
27
+ * outward symlink). Multi-root sessions run the two-step select-then-confine:
28
+ * {@link selectRoot} commits to exactly one declared root FIRST, then
29
+ * {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
30
+ * that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
31
+ * rebound to the sibling.
15
32
  *
16
- * Two layers: (1) a lexical check that the resolved absolute path is within root
17
- * (rejects `../` and absolute-outside before touching the FS); (2) a symlink
18
- * check that the real target or, for a new path, its nearest existing parent —
19
- * resolves inside the *real* root. The root is realpath'd too, so this is correct
20
- * even when the root itself sits under a symlink (e.g. macOS `/var → /private/var`).
33
+ * `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
34
+ * selected root is NOT the primary in a multi-root session, with
35
+ * `CRUXY_E_MULTIROOT_WRITE_DEFERRED`UNLESS `ctx.checkpointsActive` is true.
36
+ * That flag is the C.26 step-3 coupling: it is set exactly when a per-root
37
+ * checkpoint gate is wired, which captures the write (snapshots its root) before
38
+ * it reaches disk — so the refusal lifts precisely when the write becomes
39
+ * rollback-able, never before (not step-wide, not lift-then-verify). With
40
+ * checkpoints disabled the flag is false, so a non-primary write is still refused
41
+ * rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
42
+ * not every declared sibling). It is a no-op in single-root (there is only the
43
+ * primary) and for reads (which never set it). The refusal is raised BEFORE any
44
+ * filesystem access.
45
+ *
46
+ * @throws {PathEscapeError} if the path escapes the selected root.
47
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
48
+ * or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
49
+ */
50
+ export declare function resolveToolPath(ctx: ToolContext, ref: RootRef, opts?: {
51
+ requireExplicit?: boolean;
52
+ deferNonPrimaryWrite?: boolean;
53
+ }): Promise<{
54
+ root: DeclaredRoot;
55
+ abs: string;
56
+ }>;
57
+ /**
58
+ * Legacy single-root entry point, retained as a thin shim over
59
+ * {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
60
+ * tools) keep working and stay byte-identical. New path tools should call
61
+ * {@link resolveToolPath} so they can carry an explicit root selector.
21
62
  *
22
- * @returns the resolved absolute path (lexical, not realpath'd — so callers
23
- * operate on the intended location).
63
+ * @returns the resolved absolute path (lexical, not realpath'd).
24
64
  * @throws {PathEscapeError} if the path escapes the root.
25
65
  */
26
66
  export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<string>;
67
+ /**
68
+ * True when a glob pattern could walk outside its root — an absolute pattern or one
69
+ * containing a `..` segment. The ONE implementation of "does this pattern escape",
70
+ * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
71
+ * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
72
+ * *patterns* are not resolvable paths, so this predicate guards the walk instead.
73
+ */
74
+ export declare function isEscapingPattern(pattern: string): boolean;
75
+ /**
76
+ * The separator between a root's name and a path within it (`‹root› ▸ rel`) — one
77
+ * constant so every no-path tool that fans across roots renders the same label.
78
+ */
79
+ export declare const ROOT_LABEL_SEP = " \u25B8 ";
80
+ /**
81
+ * Prefix a root-relative path with its root name — but ONLY in a genuine
82
+ * multi-root session. In single-root the label is dropped so output stays
83
+ * byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
84
+ * the same fan iteration that produced the bytes, so a rendered label can never
85
+ * point at a different root than the one that was walked (the honesty pin).
86
+ */
87
+ export declare function labelPath(root: DeclaredRoot, rel: string, isMultiRoot: boolean): string;
88
+ /** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
89
+ export declare function labelName(rootName: string, rel: string): string;
90
+ /**
91
+ * The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
92
+ * `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
93
+ * A no-path tool has no path to confine; its boundary is *which root's `absPath`*
94
+ * it threads as the walk-start / index-key / spawn-cwd. This returns that root
95
+ * set, and the tool commits to exactly one root per read by iterating it:
96
+ *
97
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
98
+ * - single-root session → the primary root (so output is byte-identical);
99
+ * - multi-root, unscoped → **fan** every declared root.
100
+ *
101
+ * The fan is N independent single-root reads (one `root.absPath` each), never one
102
+ * walk over a union — so each read is Funnel-B-confined and the cross-root merge
103
+ * only ever happens on already-attributed *results*, never on the filesystem.
104
+ * Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
105
+ *
106
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
107
+ */
108
+ export declare function resolveReadRoots(ctx: ToolContext, ref?: {
109
+ root?: string;
110
+ }): readonly DeclaredRoot[];
111
+ /**
112
+ * The Funnel-B selector for a **no-path, whole-root MUTATION** — today
113
+ * `create_pull_request` (and the future commit/branch/push tools). Unlike
114
+ * {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
115
+ * session, refuses to default (⚖︎#11, JC-1 take B):
116
+ *
117
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
118
+ * - single-root session → the primary root (byte-identical to pre-C.26 — a
119
+ * single root is unambiguous, so no `root` is required);
120
+ * - multi-root, unscoped → refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
121
+ * "opened the wrong repo" accident; ambiguity only exists when N > 1).
122
+ *
123
+ * `what` names the action in the refusal message (e.g. `"a pull request"`). This
124
+ * throws BEFORE the caller runs any git, so a no-root multi-root call performs
125
+ * zero git spawns.
126
+ *
127
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
128
+ * (multi-root, no root).
129
+ */
130
+ export declare function resolveMutationRoot(ctx: ToolContext, ref: {
131
+ root?: string;
132
+ }, what: string): DeclaredRoot;
@@ -1,67 +1,175 @@
1
- import { promises as fs } from "node:fs";
2
1
  import path from "node:path";
3
- import { CruxyError, ErrorCode } from "../../errors/index.js";
2
+ import { multirootWriteDeferred, rootAmbiguous } from "../../errors/index.js";
3
+ import { confineToRoot, PathEscapeError, selectRoot, singleRootWorkspace, } from "../../workspace/index.js";
4
4
  /**
5
- * Thrown when a tool argument resolves to a path outside the project root —
6
- * whether via `../` traversal, an absolute path, or a symlink pointing outward.
7
- * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
8
- * reaches the boundary; tools still catch it and surface `{ ok:false }`.
5
+ * Path confinement for file tools (C.26). The confinement kernel itself lives in
6
+ * `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
7
+ * that every path-taking tool calls instead of touching the filesystem directly.
8
+ * Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
9
+ * outside the selected root") a structural property rather than a per-tool habit.
9
10
  */
10
- export class PathEscapeError extends CruxyError {
11
- constructor(message) {
12
- super({ code: ErrorCode.PathEscape, title: message });
13
- this.name = "PathEscapeError";
11
+ export { PathEscapeError };
12
+ /**
13
+ * The workspace this context acts in. Runtime always sets `ctx.workspace`; the
14
+ * fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
15
+ * many test contexts (and any not-yet-migrated caller) that don't set one. The
16
+ * fallback is byte-identical to the legacy single-root behaviour by construction:
17
+ * its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
18
+ * `resolveInRoot` confined to.
19
+ */
20
+ export function contextWorkspace(ctx) {
21
+ return ctx.workspace ?? singleRootWorkspace(ctx.cwd);
22
+ }
23
+ /**
24
+ * Resolve a tool-supplied path against the workspace and prove it stays inside the
25
+ * one root it selects — the single funnel for every path-taking file tool.
26
+ *
27
+ * Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
28
+ * which is exactly the legacy `resolveInRoot` behaviour (same result, same
29
+ * error type + message for every case: relative, absolute-inside, absolute-outside,
30
+ * outward symlink). Multi-root sessions run the two-step select-then-confine:
31
+ * {@link selectRoot} commits to exactly one declared root FIRST, then
32
+ * {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
33
+ * that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
34
+ * rebound to the sibling.
35
+ *
36
+ * `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
37
+ * selected root is NOT the primary in a multi-root session, with
38
+ * `CRUXY_E_MULTIROOT_WRITE_DEFERRED` — UNLESS `ctx.checkpointsActive` is true.
39
+ * That flag is the C.26 step-3 coupling: it is set exactly when a per-root
40
+ * checkpoint gate is wired, which captures the write (snapshots its root) before
41
+ * it reaches disk — so the refusal lifts precisely when the write becomes
42
+ * rollback-able, never before (not step-wide, not lift-then-verify). With
43
+ * checkpoints disabled the flag is false, so a non-primary write is still refused
44
+ * rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
45
+ * not every declared sibling). It is a no-op in single-root (there is only the
46
+ * primary) and for reads (which never set it). The refusal is raised BEFORE any
47
+ * filesystem access.
48
+ *
49
+ * @throws {PathEscapeError} if the path escapes the selected root.
50
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
51
+ * or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
52
+ */
53
+ export async function resolveToolPath(ctx, ref, opts = {}) {
54
+ const ws = contextWorkspace(ctx);
55
+ // Single-root: byte-identical with the legacy single-root funnel. Skipping
56
+ // selectRoot here also means a bare path segment that happens to equal the sole
57
+ // root's name is never mis-stripped as a root prefix. (A single-root session has
58
+ // only the primary, so the non-primary-write guard can never fire here.)
59
+ if (!ws.isMultiRoot) {
60
+ const root = ws.primary();
61
+ return { root, abs: await confineToRoot(root.absPath, ref.path) };
14
62
  }
63
+ const selected = selectRoot(ws, ref, opts);
64
+ if (opts.deferNonPrimaryWrite &&
65
+ selected.root.name !== ws.primary().name &&
66
+ !ctx.checkpointsActive) {
67
+ // Refuse BEFORE confineToRoot touches the filesystem — no partial write. The
68
+ // lift is conditioned on the per-root checkpoint gate being wired
69
+ // (ctx.checkpointsActive): permitting the write and capturing it are the same
70
+ // decision, so a permitted non-primary write is always checkpointed.
71
+ throw multirootWriteDeferred(selected.root.name, ws.primary().name);
72
+ }
73
+ return {
74
+ root: selected.root,
75
+ abs: await confineToRoot(selected.root.absPath, selected.relPath),
76
+ };
77
+ }
78
+ /**
79
+ * Legacy single-root entry point, retained as a thin shim over
80
+ * {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
81
+ * tools) keep working and stay byte-identical. New path tools should call
82
+ * {@link resolveToolPath} so they can carry an explicit root selector.
83
+ *
84
+ * @returns the resolved absolute path (lexical, not realpath'd).
85
+ * @throws {PathEscapeError} if the path escapes the root.
86
+ */
87
+ export async function resolveInRoot(ctx, p) {
88
+ return (await resolveToolPath(ctx, { path: p })).abs;
15
89
  }
16
- /** Is `target` the root itself or a descendant of it? */
17
- function isInside(root, target) {
18
- return target === root || target.startsWith(root + path.sep);
90
+ /**
91
+ * True when a glob pattern could walk outside its root an absolute pattern or one
92
+ * containing a `..` segment. The ONE implementation of "does this pattern escape",
93
+ * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
94
+ * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
95
+ * *patterns* are not resolvable paths, so this predicate guards the walk instead.
96
+ */
97
+ export function isEscapingPattern(pattern) {
98
+ return path.isAbsolute(pattern) || pattern.split(/[/\\]/).includes("..");
19
99
  }
20
100
  /**
21
- * realpath `p`, or if it doesn't exist yet the realpath of its nearest
22
- * existing ancestor directory. Lets us validate a not-yet-created path by the
23
- * directory it would be created in (catching outward symlinked parents).
101
+ * The separator between a root's name and a path within it (`‹root› rel`) — one
102
+ * constant so every no-path tool that fans across roots renders the same label.
103
+ */
104
+ export const ROOT_LABEL_SEP = " ▸ ";
105
+ /**
106
+ * Prefix a root-relative path with its root name — but ONLY in a genuine
107
+ * multi-root session. In single-root the label is dropped so output stays
108
+ * byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
109
+ * the same fan iteration that produced the bytes, so a rendered label can never
110
+ * point at a different root than the one that was walked (the honesty pin).
24
111
  */
25
- async function realpathOfNearestExisting(p) {
26
- let cur = p;
27
- for (;;) {
28
- try {
29
- return await fs.realpath(cur);
30
- }
31
- catch (err) {
32
- if (err.code !== "ENOENT")
33
- throw err;
34
- const parent = path.dirname(cur);
35
- if (parent === cur)
36
- return cur; // reached the filesystem root
37
- cur = parent;
38
- }
112
+ export function labelPath(root, rel, isMultiRoot) {
113
+ return isMultiRoot ? labelName(root.name, rel) : rel;
114
+ }
115
+ /** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
116
+ export function labelName(rootName, rel) {
117
+ return `${rootName}${ROOT_LABEL_SEP}${rel}`;
118
+ }
119
+ /**
120
+ * The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
121
+ * `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
122
+ * A no-path tool has no path to confine; its boundary is *which root's `absPath`*
123
+ * it threads as the walk-start / index-key / spawn-cwd. This returns that root
124
+ * set, and the tool commits to exactly one root per read by iterating it:
125
+ *
126
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
127
+ * - single-root session → the primary root (so output is byte-identical);
128
+ * - multi-root, unscoped → **fan** every declared root.
129
+ *
130
+ * The fan is N independent single-root reads (one `root.absPath` each), never one
131
+ * walk over a union — so each read is Funnel-B-confined and the cross-root merge
132
+ * only ever happens on already-attributed *results*, never on the filesystem.
133
+ * Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
134
+ *
135
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
136
+ */
137
+ export function resolveReadRoots(ctx, ref = {}) {
138
+ const ws = contextWorkspace(ctx);
139
+ if (ref.root !== undefined) {
140
+ return [ws.rootByName(ref.root)];
141
+ }
142
+ if (!ws.isMultiRoot) {
143
+ return [ws.primary()];
39
144
  }
145
+ return ws.roots();
40
146
  }
41
147
  /**
42
- * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
43
- * stays inside the single security boundary every file tool funnels through.
148
+ * The Funnel-B selector for a **no-path, whole-root MUTATION** today
149
+ * `create_pull_request` (and the future commit/branch/push tools). Unlike
150
+ * {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
151
+ * session, refuses to default (⚖︎#11, JC-1 take B):
44
152
  *
45
- * Two layers: (1) a lexical check that the resolved absolute path is within root
46
- * (rejects `../` and absolute-outside before touching the FS); (2) a symlink
47
- * check that the real target — or, for a new path, its nearest existing parent —
48
- * resolves inside the *real* root. The root is realpath'd too, so this is correct
49
- * even when the root itself sits under a symlink (e.g. macOS `/var → /private/var`).
153
+ * - an explicit `root` name that one declared root (fail-loud on unknown, R1);
154
+ * - single-root session the primary root (byte-identical to pre-C.26 — a
155
+ * single root is unambiguous, so no `root` is required);
156
+ * - multi-root, unscoped refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
157
+ * "opened the wrong repo" accident; ambiguity only exists when N > 1).
50
158
  *
51
- * @returns the resolved absolute path (lexical, not realpath'd so callers
52
- * operate on the intended location).
53
- * @throws {PathEscapeError} if the path escapes the root.
159
+ * `what` names the action in the refusal message (e.g. `"a pull request"`). This
160
+ * throws BEFORE the caller runs any git, so a no-root multi-root call performs
161
+ * zero git spawns.
162
+ *
163
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
164
+ * (multi-root, no root).
54
165
  */
55
- export async function resolveInRoot(ctx, p) {
56
- const root = path.resolve(ctx.cwd);
57
- const resolved = path.resolve(root, p);
58
- if (!isInside(root, resolved)) {
59
- throw new PathEscapeError(`path "${p}" resolves outside the project root`);
166
+ export function resolveMutationRoot(ctx, ref, what) {
167
+ const ws = contextWorkspace(ctx);
168
+ if (ref.root !== undefined) {
169
+ return ws.rootByName(ref.root);
60
170
  }
61
- const realRoot = await fs.realpath(root);
62
- const realTarget = await realpathOfNearestExisting(resolved);
63
- if (!isInside(realRoot, realTarget)) {
64
- throw new PathEscapeError(`path "${p}" resolves outside the project root (via a symlink)`);
171
+ if (!ws.isMultiRoot) {
172
+ return ws.primary();
65
173
  }
66
- return resolved;
174
+ throw rootAmbiguous(what, ws.roots().map((r) => r.name));
67
175
  }
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
- import { resolveInRoot } from "./paths.js";
3
+ import { resolveToolPath } from "./paths.js";
4
4
  /** Files larger than this are truncated rather than dumped in full. */
5
5
  const MAX_BYTES = 256 * 1024;
6
6
  /**
@@ -16,7 +16,7 @@ export const readFileTool = {
16
16
  }),
17
17
  async execute(input, ctx) {
18
18
  try {
19
- const abs = await resolveInRoot(ctx, input.path);
19
+ const { abs } = await resolveToolPath(ctx, { path: input.path });
20
20
  const stat = await fs.stat(abs);
21
21
  if (stat.isDirectory()) {
22
22
  return {
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { resolveInRoot } from "./paths.js";
4
+ import { resolveToolPath } from "./paths.js";
5
5
  /** How many leading lines of new content the approval preview shows. */
6
6
  const PREVIEW_LINES = 20;
7
7
  /**
@@ -20,7 +20,7 @@ export const writeFileTool = {
20
20
  async execute(input, ctx) {
21
21
  let abs;
22
22
  try {
23
- abs = await resolveInRoot(ctx, input.path);
23
+ ({ abs } = await resolveToolPath(ctx, { path: input.path }, { deferNonPrimaryWrite: true }));
24
24
  }
25
25
  catch (err) {
26
26
  return { ok: false, error: err.message };
@@ -4,5 +4,12 @@ import type { Tool } from "./types.js";
4
4
  * Report the current git branch and working-tree status (`git status
5
5
  * --porcelain`) for the project root. Read-only — no approval, like read_file
6
6
  * and glob.
7
+ *
8
+ * Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
9
+ * root — each `getGitStatus(root.absPath)` spawns git with that root as its cwd,
10
+ * so a section reports exactly the working tree its label names. A non-git root
11
+ * is NAMED (not fatal), and a `root` argument scopes to one root.
7
12
  */
8
- export declare const gitStatusTool: Tool<z.ZodObject<Record<string, never>>>;
13
+ export declare const gitStatusTool: Tool<z.ZodObject<{
14
+ root: z.ZodOptional<z.ZodString>;
15
+ }>>;