@cruxy/cli 0.21.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/cli/session-factory.js +11 -0
- package/dist/config/schema.d.ts +134 -8
- package/dist/config/schema.js +45 -1
- package/dist/errors/constructors.d.ts +66 -0
- package/dist/errors/constructors.js +186 -0
- package/dist/errors/types.d.ts +43 -0
- package/dist/errors/types.js +64 -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/web/demarcate.d.ts +13 -0
- package/dist/web/demarcate.js +78 -0
- package/dist/web/fetch.d.ts +11 -0
- package/dist/web/fetch.js +174 -0
- package/dist/web/index.d.ts +7 -0
- package/dist/web/index.js +7 -0
- package/dist/web/provider.d.ts +29 -0
- package/dist/web/provider.js +77 -0
- package/dist/web/search.d.ts +17 -0
- package/dist/web/search.js +42 -0
- package/dist/web/ssrf.d.ts +55 -0
- package/dist/web/ssrf.js +223 -0
- package/dist/web/tools.d.ts +20 -0
- package/dist/web/tools.js +81 -0
- package/dist/web/types.d.ts +62 -0
- package/dist/web/types.js +1 -0
- 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 +2 -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
|
10
10
|
import { routerForConfig } from "../routing/index.js";
|
|
11
11
|
import { MemoryService, rememberTool } from "../memory/index.js";
|
|
12
12
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
13
|
+
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
13
14
|
import { appendRun } from "../usage/index.js";
|
|
14
15
|
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
15
16
|
/**
|
|
@@ -148,6 +149,16 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
148
149
|
execRegistry.register(getDiagnosticsTool);
|
|
149
150
|
execRegistry.register(hoverTool);
|
|
150
151
|
}
|
|
152
|
+
// Web search + fetch (C.20): register the two read-only web tools only when
|
|
153
|
+
// enabled. They reach the PUBLIC internet and inject attacker-controllable data
|
|
154
|
+
// (results/pages are demarcated as untrusted and never persisted), so — like
|
|
155
|
+
// LSP and MCP — the feature is opt-in; when off, neither tool is registered and
|
|
156
|
+
// no search provider is ever constructed. Read-only (no approval), so they
|
|
157
|
+
// bypass the U.3 gate like search_codebase.
|
|
158
|
+
if (config.web.enabled) {
|
|
159
|
+
execRegistry.register(createWebSearchTool());
|
|
160
|
+
execRegistry.register(createWebFetchTool());
|
|
161
|
+
}
|
|
151
162
|
// MCP servers (C.27): the caller connected + trusted the servers and produced
|
|
152
163
|
// these tools through the single adapter seam BEFORE building the session, so
|
|
153
164
|
// registration here is a plain hand-off — every one is destructive-gated,
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -44,15 +44,12 @@ export declare const AgentConfigSchema: z.ZodObject<{
|
|
|
44
44
|
export declare const ToolsConfigSchema: z.ZodObject<{
|
|
45
45
|
fileEdit: z.ZodDefault<z.ZodBoolean>;
|
|
46
46
|
shell: z.ZodDefault<z.ZodBoolean>;
|
|
47
|
-
webSearch: z.ZodDefault<z.ZodBoolean>;
|
|
48
47
|
}, "strict", z.ZodTypeAny, {
|
|
49
48
|
fileEdit: boolean;
|
|
50
49
|
shell: boolean;
|
|
51
|
-
webSearch: boolean;
|
|
52
50
|
}, {
|
|
53
51
|
fileEdit?: boolean | undefined;
|
|
54
52
|
shell?: boolean | undefined;
|
|
55
|
-
webSearch?: boolean | undefined;
|
|
56
53
|
}>;
|
|
57
54
|
export declare const GitConfigSchema: z.ZodObject<{
|
|
58
55
|
autoCommit: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -722,6 +719,69 @@ export declare const McpConfigSchema: z.ZodObject<{
|
|
|
722
719
|
maxSchemaBytes?: number | undefined;
|
|
723
720
|
}>;
|
|
724
721
|
export type McpConfig = z.infer<typeof McpConfigSchema>;
|
|
722
|
+
/**
|
|
723
|
+
* Web-search + web-fetch subtool (C.20). OFF by default. When enabled, the agent
|
|
724
|
+
* gets a bounded `web_search` (query → ranked title/url/snippet) and a `web_fetch`
|
|
725
|
+
* (read one URL as text). Both surface EXTERNAL, attacker-controllable data:
|
|
726
|
+
* results and fetched pages are wrapped as untrusted data (do-not-follow-instructions
|
|
727
|
+
* envelope, fence-forgery neutralized) with upstream model names scrubbed, and
|
|
728
|
+
* are NEVER persisted to memory/index/checkpoint. `web_fetch` refuses non-http(s)
|
|
729
|
+
* schemes and any host that resolves into a private/loopback/link-local range
|
|
730
|
+
* (SSRF guard) — the request is never dispatched. No provider is constructed and
|
|
731
|
+
* no tool is registered while this is off. Search runs through a swappable
|
|
732
|
+
* `SearchProvider` seam; the direct provider's API key comes from the environment
|
|
733
|
+
* (`apiKeyEnv`), never from config-in-repo and never logged.
|
|
734
|
+
*/
|
|
735
|
+
export declare const WebConfigSchema: z.ZodObject<{
|
|
736
|
+
/** Master switch. When false, neither tool is registered and no provider is
|
|
737
|
+
* constructed (the feature stays fully inert). */
|
|
738
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
739
|
+
/** Which search backend to use behind the `SearchProvider` seam. A gateway
|
|
740
|
+
* provider slots in here first-class if the backend ever proxies search. */
|
|
741
|
+
provider: z.ZodDefault<z.ZodEnum<["tavily"]>>;
|
|
742
|
+
/** Environment variable holding the direct provider's API key. The key is
|
|
743
|
+
* read at call time, sent only in the provider's auth field, and never
|
|
744
|
+
* logged or written to the repo. */
|
|
745
|
+
apiKeyEnv: z.ZodDefault<z.ZodString>;
|
|
746
|
+
/** Max search results returned to the model (top-N; the rest are dropped). */
|
|
747
|
+
maxResults: z.ZodDefault<z.ZodNumber>;
|
|
748
|
+
/** Max characters kept from a single result's snippet; the rest is truncated
|
|
749
|
+
* with a visible marker. */
|
|
750
|
+
snippetMaxChars: z.ZodDefault<z.ZodNumber>;
|
|
751
|
+
/** Max bytes read from a single `web_fetch` page; the rest is truncated with
|
|
752
|
+
* a visible marker (a hostile/huge page can't blow the context budget). */
|
|
753
|
+
fetchMaxBytes: z.ZodDefault<z.ZodNumber>;
|
|
754
|
+
/** Per-request timeout (search and fetch) — a slow host errors, never hangs. */
|
|
755
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
756
|
+
/** Max HTTP redirects `web_fetch` follows; each hop is re-checked by the SSRF
|
|
757
|
+
* guard so a 3xx can't bounce the request into a private range. */
|
|
758
|
+
maxRedirects: z.ZodDefault<z.ZodNumber>;
|
|
759
|
+
/** Escape hatch: allow `web_fetch` to reach private/loopback/link-local hosts.
|
|
760
|
+
* OFF by default (SSRF-safe); only set true for a deliberate internal-network
|
|
761
|
+
* use case. */
|
|
762
|
+
allowPrivateHosts: z.ZodDefault<z.ZodBoolean>;
|
|
763
|
+
}, "strict", z.ZodTypeAny, {
|
|
764
|
+
provider: "tavily";
|
|
765
|
+
timeoutMs: number;
|
|
766
|
+
apiKeyEnv: string;
|
|
767
|
+
enabled: boolean;
|
|
768
|
+
maxResults: number;
|
|
769
|
+
snippetMaxChars: number;
|
|
770
|
+
fetchMaxBytes: number;
|
|
771
|
+
maxRedirects: number;
|
|
772
|
+
allowPrivateHosts: boolean;
|
|
773
|
+
}, {
|
|
774
|
+
provider?: "tavily" | undefined;
|
|
775
|
+
timeoutMs?: number | undefined;
|
|
776
|
+
apiKeyEnv?: string | undefined;
|
|
777
|
+
enabled?: boolean | undefined;
|
|
778
|
+
maxResults?: number | undefined;
|
|
779
|
+
snippetMaxChars?: number | undefined;
|
|
780
|
+
fetchMaxBytes?: number | undefined;
|
|
781
|
+
maxRedirects?: number | undefined;
|
|
782
|
+
allowPrivateHosts?: boolean | undefined;
|
|
783
|
+
}>;
|
|
784
|
+
export type WebConfig = z.infer<typeof WebConfigSchema>;
|
|
725
785
|
export declare const CruxyConfigSchema: z.ZodObject<{
|
|
726
786
|
model: z.ZodDefault<z.ZodObject<{
|
|
727
787
|
provider: z.ZodDefault<z.ZodEnum<["cruxy", "anthropic", "openai", "custom"]>>;
|
|
@@ -765,15 +825,12 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
765
825
|
tools: z.ZodDefault<z.ZodObject<{
|
|
766
826
|
fileEdit: z.ZodDefault<z.ZodBoolean>;
|
|
767
827
|
shell: z.ZodDefault<z.ZodBoolean>;
|
|
768
|
-
webSearch: z.ZodDefault<z.ZodBoolean>;
|
|
769
828
|
}, "strict", z.ZodTypeAny, {
|
|
770
829
|
fileEdit: boolean;
|
|
771
830
|
shell: boolean;
|
|
772
|
-
webSearch: boolean;
|
|
773
831
|
}, {
|
|
774
832
|
fileEdit?: boolean | undefined;
|
|
775
833
|
shell?: boolean | undefined;
|
|
776
|
-
webSearch?: boolean | undefined;
|
|
777
834
|
}>>;
|
|
778
835
|
git: z.ZodDefault<z.ZodObject<{
|
|
779
836
|
autoCommit: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -1297,6 +1354,55 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1297
1354
|
maxDescriptionChars?: number | undefined;
|
|
1298
1355
|
maxSchemaBytes?: number | undefined;
|
|
1299
1356
|
}>>;
|
|
1357
|
+
web: z.ZodDefault<z.ZodObject<{
|
|
1358
|
+
/** Master switch. When false, neither tool is registered and no provider is
|
|
1359
|
+
* constructed (the feature stays fully inert). */
|
|
1360
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
1361
|
+
/** Which search backend to use behind the `SearchProvider` seam. A gateway
|
|
1362
|
+
* provider slots in here first-class if the backend ever proxies search. */
|
|
1363
|
+
provider: z.ZodDefault<z.ZodEnum<["tavily"]>>;
|
|
1364
|
+
/** Environment variable holding the direct provider's API key. The key is
|
|
1365
|
+
* read at call time, sent only in the provider's auth field, and never
|
|
1366
|
+
* logged or written to the repo. */
|
|
1367
|
+
apiKeyEnv: z.ZodDefault<z.ZodString>;
|
|
1368
|
+
/** Max search results returned to the model (top-N; the rest are dropped). */
|
|
1369
|
+
maxResults: z.ZodDefault<z.ZodNumber>;
|
|
1370
|
+
/** Max characters kept from a single result's snippet; the rest is truncated
|
|
1371
|
+
* with a visible marker. */
|
|
1372
|
+
snippetMaxChars: z.ZodDefault<z.ZodNumber>;
|
|
1373
|
+
/** Max bytes read from a single `web_fetch` page; the rest is truncated with
|
|
1374
|
+
* a visible marker (a hostile/huge page can't blow the context budget). */
|
|
1375
|
+
fetchMaxBytes: z.ZodDefault<z.ZodNumber>;
|
|
1376
|
+
/** Per-request timeout (search and fetch) — a slow host errors, never hangs. */
|
|
1377
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
1378
|
+
/** Max HTTP redirects `web_fetch` follows; each hop is re-checked by the SSRF
|
|
1379
|
+
* guard so a 3xx can't bounce the request into a private range. */
|
|
1380
|
+
maxRedirects: z.ZodDefault<z.ZodNumber>;
|
|
1381
|
+
/** Escape hatch: allow `web_fetch` to reach private/loopback/link-local hosts.
|
|
1382
|
+
* OFF by default (SSRF-safe); only set true for a deliberate internal-network
|
|
1383
|
+
* use case. */
|
|
1384
|
+
allowPrivateHosts: z.ZodDefault<z.ZodBoolean>;
|
|
1385
|
+
}, "strict", z.ZodTypeAny, {
|
|
1386
|
+
provider: "tavily";
|
|
1387
|
+
timeoutMs: number;
|
|
1388
|
+
apiKeyEnv: string;
|
|
1389
|
+
enabled: boolean;
|
|
1390
|
+
maxResults: number;
|
|
1391
|
+
snippetMaxChars: number;
|
|
1392
|
+
fetchMaxBytes: number;
|
|
1393
|
+
maxRedirects: number;
|
|
1394
|
+
allowPrivateHosts: boolean;
|
|
1395
|
+
}, {
|
|
1396
|
+
provider?: "tavily" | undefined;
|
|
1397
|
+
timeoutMs?: number | undefined;
|
|
1398
|
+
apiKeyEnv?: string | undefined;
|
|
1399
|
+
enabled?: boolean | undefined;
|
|
1400
|
+
maxResults?: number | undefined;
|
|
1401
|
+
snippetMaxChars?: number | undefined;
|
|
1402
|
+
fetchMaxBytes?: number | undefined;
|
|
1403
|
+
maxRedirects?: number | undefined;
|
|
1404
|
+
allowPrivateHosts?: boolean | undefined;
|
|
1405
|
+
}>>;
|
|
1300
1406
|
logLevel: z.ZodDefault<z.ZodEnum<["debug", "info", "warn", "error", "silent"]>>;
|
|
1301
1407
|
}, "strict", z.ZodTypeAny, {
|
|
1302
1408
|
cruxy: {
|
|
@@ -1368,7 +1474,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1368
1474
|
tools: {
|
|
1369
1475
|
fileEdit: boolean;
|
|
1370
1476
|
shell: boolean;
|
|
1371
|
-
webSearch: boolean;
|
|
1372
1477
|
};
|
|
1373
1478
|
git: {
|
|
1374
1479
|
autoCommit: boolean;
|
|
@@ -1431,6 +1536,17 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1431
1536
|
maxDescriptionChars: number;
|
|
1432
1537
|
maxSchemaBytes: number;
|
|
1433
1538
|
};
|
|
1539
|
+
web: {
|
|
1540
|
+
provider: "tavily";
|
|
1541
|
+
timeoutMs: number;
|
|
1542
|
+
apiKeyEnv: string;
|
|
1543
|
+
enabled: boolean;
|
|
1544
|
+
maxResults: number;
|
|
1545
|
+
snippetMaxChars: number;
|
|
1546
|
+
fetchMaxBytes: number;
|
|
1547
|
+
maxRedirects: number;
|
|
1548
|
+
allowPrivateHosts: boolean;
|
|
1549
|
+
};
|
|
1434
1550
|
logLevel: "debug" | "info" | "warn" | "error" | "silent";
|
|
1435
1551
|
}, {
|
|
1436
1552
|
cruxy?: {
|
|
@@ -1502,7 +1618,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1502
1618
|
tools?: {
|
|
1503
1619
|
fileEdit?: boolean | undefined;
|
|
1504
1620
|
shell?: boolean | undefined;
|
|
1505
|
-
webSearch?: boolean | undefined;
|
|
1506
1621
|
} | undefined;
|
|
1507
1622
|
git?: {
|
|
1508
1623
|
autoCommit?: boolean | undefined;
|
|
@@ -1565,6 +1680,17 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1565
1680
|
maxDescriptionChars?: number | undefined;
|
|
1566
1681
|
maxSchemaBytes?: number | undefined;
|
|
1567
1682
|
} | undefined;
|
|
1683
|
+
web?: {
|
|
1684
|
+
provider?: "tavily" | undefined;
|
|
1685
|
+
timeoutMs?: number | undefined;
|
|
1686
|
+
apiKeyEnv?: string | undefined;
|
|
1687
|
+
enabled?: boolean | undefined;
|
|
1688
|
+
maxResults?: number | undefined;
|
|
1689
|
+
snippetMaxChars?: number | undefined;
|
|
1690
|
+
fetchMaxBytes?: number | undefined;
|
|
1691
|
+
maxRedirects?: number | undefined;
|
|
1692
|
+
allowPrivateHosts?: boolean | undefined;
|
|
1693
|
+
} | undefined;
|
|
1568
1694
|
logLevel?: "debug" | "info" | "warn" | "error" | "silent" | undefined;
|
|
1569
1695
|
}>;
|
|
1570
1696
|
export type CruxyConfig = z.infer<typeof CruxyConfigSchema>;
|