@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.
Files changed (49) hide show
  1. package/dist/approval/classify.js +7 -3
  2. package/dist/approval/policy.d.ts +6 -0
  3. package/dist/approval/policy.js +15 -3
  4. package/dist/approval/types.d.ts +8 -1
  5. package/dist/checkpoint/index.d.ts +1 -0
  6. package/dist/checkpoint/index.js +1 -0
  7. package/dist/checkpoint/set.d.ts +44 -0
  8. package/dist/checkpoint/set.js +142 -0
  9. package/dist/checkpoint/types.d.ts +47 -0
  10. package/dist/cli/session-factory.js +11 -0
  11. package/dist/config/schema.d.ts +134 -8
  12. package/dist/config/schema.js +45 -1
  13. package/dist/errors/constructors.d.ts +66 -0
  14. package/dist/errors/constructors.js +186 -0
  15. package/dist/errors/types.d.ts +43 -0
  16. package/dist/errors/types.js +64 -0
  17. package/dist/sandbox/docker-runtime.js +4 -1
  18. package/dist/sandbox/policy.d.ts +12 -3
  19. package/dist/sandbox/policy.js +17 -3
  20. package/dist/sandbox/types.d.ts +10 -1
  21. package/dist/tools/file/paths.d.ts +10 -17
  22. package/dist/tools/file/paths.js +11 -58
  23. package/dist/web/demarcate.d.ts +13 -0
  24. package/dist/web/demarcate.js +78 -0
  25. package/dist/web/fetch.d.ts +11 -0
  26. package/dist/web/fetch.js +174 -0
  27. package/dist/web/index.d.ts +7 -0
  28. package/dist/web/index.js +7 -0
  29. package/dist/web/provider.d.ts +29 -0
  30. package/dist/web/provider.js +77 -0
  31. package/dist/web/search.d.ts +17 -0
  32. package/dist/web/search.js +42 -0
  33. package/dist/web/ssrf.d.ts +55 -0
  34. package/dist/web/ssrf.js +223 -0
  35. package/dist/web/tools.d.ts +20 -0
  36. package/dist/web/tools.js +81 -0
  37. package/dist/web/types.d.ts +62 -0
  38. package/dist/web/types.js +1 -0
  39. package/dist/workspace/index.d.ts +5 -0
  40. package/dist/workspace/index.js +3 -0
  41. package/dist/workspace/resolve.d.ts +54 -0
  42. package/dist/workspace/resolve.js +96 -0
  43. package/dist/workspace/select.d.ts +41 -0
  44. package/dist/workspace/select.js +44 -0
  45. package/dist/workspace/types.d.ts +30 -0
  46. package/dist/workspace/types.js +15 -0
  47. package/dist/workspace/workspace.d.ts +56 -0
  48. package/dist/workspace/workspace.js +180 -0
  49. 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 === "" ? { 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
+ }
@@ -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,
@@ -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>;