@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
@@ -38,7 +38,6 @@ export const ToolsConfigSchema = z
38
38
  .object({
39
39
  fileEdit: z.boolean().default(true),
40
40
  shell: z.boolean().default(true),
41
- webSearch: z.boolean().default(false), // C.17
42
41
  })
43
42
  .strict();
44
43
  export const GitConfigSchema = z
@@ -424,6 +423,50 @@ export const McpConfigSchema = z
424
423
  maxSchemaBytes: z.number().int().positive().default(8192),
425
424
  })
426
425
  .strict();
426
+ /**
427
+ * Web-search + web-fetch subtool (C.20). OFF by default. When enabled, the agent
428
+ * gets a bounded `web_search` (query → ranked title/url/snippet) and a `web_fetch`
429
+ * (read one URL as text). Both surface EXTERNAL, attacker-controllable data:
430
+ * results and fetched pages are wrapped as untrusted data (do-not-follow-instructions
431
+ * envelope, fence-forgery neutralized) with upstream model names scrubbed, and
432
+ * are NEVER persisted to memory/index/checkpoint. `web_fetch` refuses non-http(s)
433
+ * schemes and any host that resolves into a private/loopback/link-local range
434
+ * (SSRF guard) — the request is never dispatched. No provider is constructed and
435
+ * no tool is registered while this is off. Search runs through a swappable
436
+ * `SearchProvider` seam; the direct provider's API key comes from the environment
437
+ * (`apiKeyEnv`), never from config-in-repo and never logged.
438
+ */
439
+ export const WebConfigSchema = z
440
+ .object({
441
+ /** Master switch. When false, neither tool is registered and no provider is
442
+ * constructed (the feature stays fully inert). */
443
+ enabled: z.boolean().default(false),
444
+ /** Which search backend to use behind the `SearchProvider` seam. A gateway
445
+ * provider slots in here first-class if the backend ever proxies search. */
446
+ provider: z.enum(["tavily"]).default("tavily"),
447
+ /** Environment variable holding the direct provider's API key. The key is
448
+ * read at call time, sent only in the provider's auth field, and never
449
+ * logged or written to the repo. */
450
+ apiKeyEnv: z.string().min(1).default("TAVILY_API_KEY"),
451
+ /** Max search results returned to the model (top-N; the rest are dropped). */
452
+ maxResults: z.number().int().positive().max(20).default(5),
453
+ /** Max characters kept from a single result's snippet; the rest is truncated
454
+ * with a visible marker. */
455
+ snippetMaxChars: z.number().int().positive().default(500),
456
+ /** Max bytes read from a single `web_fetch` page; the rest is truncated with
457
+ * a visible marker (a hostile/huge page can't blow the context budget). */
458
+ fetchMaxBytes: z.number().int().positive().default(524288),
459
+ /** Per-request timeout (search and fetch) — a slow host errors, never hangs. */
460
+ timeoutMs: z.number().int().positive().default(15000),
461
+ /** Max HTTP redirects `web_fetch` follows; each hop is re-checked by the SSRF
462
+ * guard so a 3xx can't bounce the request into a private range. */
463
+ maxRedirects: z.number().int().min(0).default(3),
464
+ /** Escape hatch: allow `web_fetch` to reach private/loopback/link-local hosts.
465
+ * OFF by default (SSRF-safe); only set true for a deliberate internal-network
466
+ * use case. */
467
+ allowPrivateHosts: z.boolean().default(false),
468
+ })
469
+ .strict();
427
470
  export const CruxyConfigSchema = z
428
471
  .object({
429
472
  model: ModelConfigSchema.default({}),
@@ -445,6 +488,7 @@ export const CruxyConfigSchema = z
445
488
  memory: MemoryConfigSchema.default({}),
446
489
  usage: UsageConfigSchema.default({}),
447
490
  mcp: McpConfigSchema.default({}),
491
+ web: WebConfigSchema.default({}),
448
492
  logLevel: z.enum(LOG_LEVELS).default("info"),
449
493
  })
450
494
  .strict();
@@ -119,6 +119,43 @@ export declare function checkpointNotFound(id?: string): CruxyError;
119
119
  * Restoring is destructive and deliberate — there is no auto-rollback path, ever.
120
120
  */
121
121
  export declare function rollbackApprovalRequired(): CruxyError;
122
+ /**
123
+ * A path/selector names a workspace root that is not in the declared set — an
124
+ * unknown root name, or an absolute path that lands in no declared root. Fail-loud
125
+ * by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
126
+ * a cross-root misfire. Refused before any FS access.
127
+ */
128
+ export declare function rootUnknown(ref: string, known: readonly string[]): CruxyError;
129
+ /**
130
+ * A path is ambiguous across the declared root set: an absolute path inside ≥2
131
+ * declared roots, or a mutating tool given no root when there is no unambiguous
132
+ * choice. Writes fail closed rather than guess which repo to touch.
133
+ */
134
+ export declare function rootAmbiguous(ref: string, candidates: string[]): CruxyError;
135
+ /**
136
+ * Declaration-time: a declared root nests inside / overlaps another. Refused at
137
+ * session start — overlap makes "which root owns this path" ambiguous and lets two
138
+ * checkpoints/grants fight over the same bytes.
139
+ */
140
+ export declare function rootOverlap(a: string, b: string): CruxyError;
141
+ /**
142
+ * An interactive add-root was refused: no TTY to confirm, or the user declined the
143
+ * confirm/trust prompt. The root set only ever grows by an explicit human act —
144
+ * never the model, never a repo-local config.
145
+ */
146
+ export declare function rootAddRefused(reason: string): CruxyError;
147
+ /**
148
+ * A multi-root rollback set references a member checkpoint that is missing or
149
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
150
+ * masquerade as success.
151
+ */
152
+ export declare function checkpointSetIncomplete(runId: string, reason: string): CruxyError;
153
+ /**
154
+ * A multi-root rollback failed mid-apply (R3): it restored some roots and not
155
+ * others, and it STOPPED rather than continue best-effort. Carries the exact
156
+ * restored-vs-not split; re-running rollback is idempotent and safe.
157
+ */
158
+ export declare function checkpointSetPartial(runId: string, restored: string[], notRestored: string[], underlying?: unknown): CruxyError;
122
159
  /**
123
160
  * A subagent spawn was attempted past the configured nesting cap (C.14). The
124
161
  * spawn tool is structurally withheld at the cap, so reaching this means the
@@ -197,6 +234,35 @@ export declare function mcpUntrusted(root: string, servers: string[]): CruxyErro
197
234
  * gag-scrubbed (U.8) before it reaches the user-facing cause.
198
235
  */
199
236
  export declare function mcpConnect(server: string, underlying?: unknown): CruxyError;
237
+ /**
238
+ * `web.enabled` is on but no usable search provider is configured — the API-key
239
+ * environment variable is unset (or the provider is unknown). THE HONESTY RULE:
240
+ * this is a coded, actionable failure, never an empty result — "no provider"
241
+ * must not read as "the web had no results". `apiKeyEnv` names the variable to set.
242
+ */
243
+ export declare function webUnavailable(provider: string, apiKeyEnv: string): CruxyError;
244
+ /**
245
+ * A web search did not complete — the provider returned an HTTP error, the network
246
+ * failed, or the request timed out. Distinct from a search that ran and found
247
+ * nothing (that stays an ordinary `ok:true` empty result). External provider text
248
+ * is gag-scrubbed (U.8) before it reaches the user-facing cause.
249
+ */
250
+ export declare function webSearchFailed(underlying?: unknown): CruxyError;
251
+ /**
252
+ * A `web_fetch` failed to retrieve a page: network error, timeout, an oversize or
253
+ * non-text body, or too many redirects. Distinct from a page that was fetched but
254
+ * held no readable text (an ordinary `ok:true` empty result). External response
255
+ * text is gag-scrubbed (U.8) before it reaches the user-facing cause.
256
+ */
257
+ export declare function webFetchFailed(url: string, underlying?: unknown): CruxyError;
258
+ /**
259
+ * A `web_fetch` was REFUSED before any request went out (SSRF guard): a non-http(s)
260
+ * scheme, or a host that resolves into a private/loopback/link-local range
261
+ * (127.0.0.1, 169.254.169.254 cloud metadata, 10.x, internal DNS, …). A security
262
+ * stop kept distinct from an ordinary fetch failure — the request is never
263
+ * dispatched. `web.allowPrivateHosts` is the deliberate escape hatch.
264
+ */
265
+ export declare function webBlockedHost(url: string, reason: string): CruxyError;
200
266
  export declare function internal(underlying?: unknown): CruxyError;
201
267
  /**
202
268
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -551,6 +551,115 @@ export function rollbackApprovalRequired() {
551
551
  ],
552
552
  });
553
553
  }
554
+ // ── multi-repo / workspace (exit 18) — C.26 ───────────────────────────────────
555
+ /**
556
+ * A path/selector names a workspace root that is not in the declared set — an
557
+ * unknown root name, or an absolute path that lands in no declared root. Fail-loud
558
+ * by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
559
+ * a cross-root misfire. Refused before any FS access.
560
+ */
561
+ export function rootUnknown(ref, known) {
562
+ return new CruxyError({
563
+ code: ErrorCode.RootUnknown,
564
+ title: `no workspace root named "${ref}"`,
565
+ cause: "the root set is fixed at session start and matched exactly — never by prefix or nearest-name",
566
+ nextSteps: [
567
+ known.length
568
+ ? `declared roots: ${known.join(", ")}`
569
+ : "no additional roots are declared this session",
570
+ "pass --root <name>=<path> at startup, or add one interactively",
571
+ ],
572
+ meta: { ref, known: [...known] },
573
+ });
574
+ }
575
+ /**
576
+ * A path is ambiguous across the declared root set: an absolute path inside ≥2
577
+ * declared roots, or a mutating tool given no root when there is no unambiguous
578
+ * choice. Writes fail closed rather than guess which repo to touch.
579
+ */
580
+ export function rootAmbiguous(ref, candidates) {
581
+ return new CruxyError({
582
+ code: ErrorCode.RootAmbiguous,
583
+ title: `"${ref}" is ambiguous across the declared roots`,
584
+ cause: candidates.length > 1
585
+ ? `it resolves inside more than one declared root: ${candidates.join(", ")}`
586
+ : "a mutating action must name exactly one root",
587
+ nextSteps: [
588
+ "name the root explicitly with the `root` argument",
589
+ "declare either the monorepo root OR its packages, never both (overlap is refused)",
590
+ ],
591
+ meta: { ref, candidates },
592
+ });
593
+ }
594
+ /**
595
+ * Declaration-time: a declared root nests inside / overlaps another. Refused at
596
+ * session start — overlap makes "which root owns this path" ambiguous and lets two
597
+ * checkpoints/grants fight over the same bytes.
598
+ */
599
+ export function rootOverlap(a, b) {
600
+ return new CruxyError({
601
+ code: ErrorCode.RootOverlap,
602
+ title: "declared workspace roots overlap",
603
+ cause: `"${a}" nests inside or equals "${b}"`,
604
+ nextSteps: [
605
+ "declare the monorepo root OR specific package roots, never both",
606
+ "remove one of the overlapping --root entries",
607
+ ],
608
+ meta: { a, b },
609
+ });
610
+ }
611
+ /**
612
+ * An interactive add-root was refused: no TTY to confirm, or the user declined the
613
+ * confirm/trust prompt. The root set only ever grows by an explicit human act —
614
+ * never the model, never a repo-local config.
615
+ */
616
+ export function rootAddRefused(reason) {
617
+ return new CruxyError({
618
+ code: ErrorCode.RootAddRefused,
619
+ title: "adding a workspace root was refused",
620
+ cause: reason,
621
+ nextSteps: [
622
+ "declare roots up front with --root at startup",
623
+ "add a root only from an interactive terminal, where it can be confirmed and trusted",
624
+ ],
625
+ meta: { reason },
626
+ });
627
+ }
628
+ /**
629
+ * A multi-root rollback set references a member checkpoint that is missing or
630
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
631
+ * masquerade as success.
632
+ */
633
+ export function checkpointSetIncomplete(runId, reason) {
634
+ return new CruxyError({
635
+ code: ErrorCode.CheckpointSetIncomplete,
636
+ title: `rollback set "${runId}" is incomplete`,
637
+ cause: reason,
638
+ nextSteps: [
639
+ "run `cruxy checkpoint list` to inspect each root's checkpoints",
640
+ "roll back an individual root's checkpoint with `cruxy rollback <id>` if needed",
641
+ ],
642
+ meta: { runId, reason },
643
+ });
644
+ }
645
+ /**
646
+ * A multi-root rollback failed mid-apply (R3): it restored some roots and not
647
+ * others, and it STOPPED rather than continue best-effort. Carries the exact
648
+ * restored-vs-not split; re-running rollback is idempotent and safe.
649
+ */
650
+ export function checkpointSetPartial(runId, restored, notRestored, underlying) {
651
+ return new CruxyError({
652
+ code: ErrorCode.CheckpointSetPartial,
653
+ title: `rollback of set "${runId}" stopped partway`,
654
+ cause: `restored: ${restored.join(", ") || "none"}; not restored: ${notRestored.join(", ") || "none"}`,
655
+ nextSteps: [
656
+ "re-run `cruxy rollback` — it recomputes each root from disk and is safe to retry",
657
+ "the not-restored roots are unchanged; no root is left half-applied silently",
658
+ ],
659
+ underlying,
660
+ meta: { runId, restored, notRestored },
661
+ });
662
+ }
554
663
  // ── subagent (exit 2 / 11) ────────────────────────────────────────────────────
555
664
  /**
556
665
  * A subagent spawn was attempted past the configured nesting cap (C.14). The
@@ -779,6 +888,83 @@ export function mcpConnect(server, underlying) {
779
888
  underlying,
780
889
  });
781
890
  }
891
+ // ── web search + fetch (exit 17) — C.20 ───────────────────────────────────────
892
+ /**
893
+ * `web.enabled` is on but no usable search provider is configured — the API-key
894
+ * environment variable is unset (or the provider is unknown). THE HONESTY RULE:
895
+ * this is a coded, actionable failure, never an empty result — "no provider"
896
+ * must not read as "the web had no results". `apiKeyEnv` names the variable to set.
897
+ */
898
+ export function webUnavailable(provider, apiKeyEnv) {
899
+ return new CruxyError({
900
+ code: ErrorCode.WebUnavailable,
901
+ title: `web search is enabled but the "${provider}" provider has no API key`,
902
+ cause: `the \`${apiKeyEnv}\` environment variable is not set`,
903
+ nextSteps: [
904
+ `export ${apiKeyEnv}=<your ${provider} api key> and re-run`,
905
+ "or set `web.enabled = false` to disable web tools for this project",
906
+ ],
907
+ meta: { provider, apiKeyEnv },
908
+ });
909
+ }
910
+ /**
911
+ * A web search did not complete — the provider returned an HTTP error, the network
912
+ * failed, or the request timed out. Distinct from a search that ran and found
913
+ * nothing (that stays an ordinary `ok:true` empty result). External provider text
914
+ * is gag-scrubbed (U.8) before it reaches the user-facing cause.
915
+ */
916
+ export function webSearchFailed(underlying) {
917
+ return new CruxyError({
918
+ code: ErrorCode.WebSearch,
919
+ title: "web search failed",
920
+ cause: scrubbedMessageOf(underlying) ??
921
+ "the search provider errored, was unreachable, or timed out",
922
+ nextSteps: [
923
+ "re-run with --verbose to see the provider's error",
924
+ "check the provider status and your network, then retry",
925
+ ],
926
+ underlying,
927
+ });
928
+ }
929
+ /**
930
+ * A `web_fetch` failed to retrieve a page: network error, timeout, an oversize or
931
+ * non-text body, or too many redirects. Distinct from a page that was fetched but
932
+ * held no readable text (an ordinary `ok:true` empty result). External response
933
+ * text is gag-scrubbed (U.8) before it reaches the user-facing cause.
934
+ */
935
+ export function webFetchFailed(url, underlying) {
936
+ return new CruxyError({
937
+ code: ErrorCode.WebFetch,
938
+ title: `could not fetch ${url}`,
939
+ cause: scrubbedMessageOf(underlying) ??
940
+ "the request errored, timed out, or returned a non-text/oversize body",
941
+ nextSteps: [
942
+ "verify the URL is reachable and serves text (html/plain/json)",
943
+ "re-run with --verbose to see the underlying error",
944
+ ],
945
+ meta: { url },
946
+ underlying,
947
+ });
948
+ }
949
+ /**
950
+ * A `web_fetch` was REFUSED before any request went out (SSRF guard): a non-http(s)
951
+ * scheme, or a host that resolves into a private/loopback/link-local range
952
+ * (127.0.0.1, 169.254.169.254 cloud metadata, 10.x, internal DNS, …). A security
953
+ * stop kept distinct from an ordinary fetch failure — the request is never
954
+ * dispatched. `web.allowPrivateHosts` is the deliberate escape hatch.
955
+ */
956
+ export function webBlockedHost(url, reason) {
957
+ return new CruxyError({
958
+ code: ErrorCode.WebBlockedHost,
959
+ title: `refused to fetch ${url}`,
960
+ cause: reason,
961
+ nextSteps: [
962
+ "fetch a public http(s) URL instead",
963
+ "if you intentionally target an internal host, set `web.allowPrivateHosts = true`",
964
+ ],
965
+ meta: { url },
966
+ });
967
+ }
782
968
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
783
969
  export function internal(underlying) {
784
970
  return new CruxyError({
@@ -102,6 +102,49 @@ export declare const ErrorCode: {
102
102
  * or list its tools. Surfaced (that server contributes no tools) rather than
103
103
  * silently swallowed; never fatal to the run. */
104
104
  readonly McpConnect: "CRUXY_E_MCP_CONNECT";
105
+ /** `web.enabled` is on but no usable search provider is configured — the API
106
+ * key env var is unset or the provider is unknown. Actionable, NEVER a silent
107
+ * empty result: "no provider" must not read as "no search results". */
108
+ readonly WebUnavailable: "CRUXY_E_WEB_UNAVAILABLE";
109
+ /** A web search failed (provider HTTP error, network failure, or timeout). The
110
+ * search did NOT run to completion — distinct from a search that ran and found
111
+ * nothing (that is an ordinary `ok:true` empty result). */
112
+ readonly WebSearch: "CRUXY_E_WEB_SEARCH";
113
+ /** A `web_fetch` failed: network error, timeout, oversize/non-text body, or too
114
+ * many redirects. Distinct from a page fetched successfully that had no readable
115
+ * text (an ordinary `ok:true` empty result). */
116
+ readonly WebFetch: "CRUXY_E_WEB_FETCH";
117
+ /** A `web_fetch` was REFUSED before any request was dispatched: a non-http(s)
118
+ * scheme, or a host that resolves into a private/loopback/link-local range
119
+ * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
120
+ * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
121
+ readonly WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST";
122
+ /** A path/selector names a workspace root that is not in the declared set (or an
123
+ * absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
124
+ * prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
125
+ * Refused before any FS access; never falls through to the host filesystem. */
126
+ readonly RootUnknown: "CRUXY_E_ROOT_UNKNOWN";
127
+ /** A path is ambiguous across the declared root set: an absolute path that falls
128
+ * inside ≥2 declared roots, or a mutating tool given no root when there's no
129
+ * unambiguous choice. Writes fail closed rather than guess a root. */
130
+ readonly RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS";
131
+ /** Declaration-time: a declared root nests inside / overlaps another. Refused at
132
+ * session start — overlap makes "which root owns this path" ambiguous and lets
133
+ * two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
134
+ * its packages, never both. */
135
+ readonly RootOverlap: "CRUXY_E_ROOT_OVERLAP";
136
+ /** An interactive add-root was refused: no TTY to confirm, or the user declined
137
+ * the confirm/trust prompt. The root set only ever grows by an explicit human
138
+ * act — never by the model or a repo-local config. */
139
+ readonly RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED";
140
+ /** A multi-root rollback set references a member checkpoint that is missing or
141
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
142
+ * masquerade as success. */
143
+ readonly CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE";
144
+ /** A multi-root rollback failed mid-apply (R3): carries which roots were restored
145
+ * and which were not. The set is left recoverable by an idempotent re-run and is
146
+ * NEVER reported as success. */
147
+ readonly CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL";
105
148
  };
106
149
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
107
150
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -120,6 +120,51 @@ export const ErrorCode = {
120
120
  * or list its tools. Surfaced (that server contributes no tools) rather than
121
121
  * silently swallowed; never fatal to the run. */
122
122
  McpConnect: "CRUXY_E_MCP_CONNECT",
123
+ // web search + fetch (exit 17) — C.20
124
+ /** `web.enabled` is on but no usable search provider is configured — the API
125
+ * key env var is unset or the provider is unknown. Actionable, NEVER a silent
126
+ * empty result: "no provider" must not read as "no search results". */
127
+ WebUnavailable: "CRUXY_E_WEB_UNAVAILABLE",
128
+ /** A web search failed (provider HTTP error, network failure, or timeout). The
129
+ * search did NOT run to completion — distinct from a search that ran and found
130
+ * nothing (that is an ordinary `ok:true` empty result). */
131
+ WebSearch: "CRUXY_E_WEB_SEARCH",
132
+ /** A `web_fetch` failed: network error, timeout, oversize/non-text body, or too
133
+ * many redirects. Distinct from a page fetched successfully that had no readable
134
+ * text (an ordinary `ok:true` empty result). */
135
+ WebFetch: "CRUXY_E_WEB_FETCH",
136
+ /** A `web_fetch` was REFUSED before any request was dispatched: a non-http(s)
137
+ * scheme, or a host that resolves into a private/loopback/link-local range
138
+ * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
139
+ * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
140
+ WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST",
141
+ // multi-repo / workspace (exit 18) — C.26
142
+ /** A path/selector names a workspace root that is not in the declared set (or an
143
+ * absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
144
+ * prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
145
+ * Refused before any FS access; never falls through to the host filesystem. */
146
+ RootUnknown: "CRUXY_E_ROOT_UNKNOWN",
147
+ /** A path is ambiguous across the declared root set: an absolute path that falls
148
+ * inside ≥2 declared roots, or a mutating tool given no root when there's no
149
+ * unambiguous choice. Writes fail closed rather than guess a root. */
150
+ RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS",
151
+ /** Declaration-time: a declared root nests inside / overlaps another. Refused at
152
+ * session start — overlap makes "which root owns this path" ambiguous and lets
153
+ * two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
154
+ * its packages, never both. */
155
+ RootOverlap: "CRUXY_E_ROOT_OVERLAP",
156
+ /** An interactive add-root was refused: no TTY to confirm, or the user declined
157
+ * the confirm/trust prompt. The root set only ever grows by an explicit human
158
+ * act — never by the model or a repo-local config. */
159
+ RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED",
160
+ /** A multi-root rollback set references a member checkpoint that is missing or
161
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
162
+ * masquerade as success. */
163
+ CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE",
164
+ /** A multi-root rollback failed mid-apply (R3): carries which roots were restored
165
+ * and which were not. The set is left recoverable by an idempotent re-run and is
166
+ * NEVER reported as success. */
167
+ CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL",
123
168
  };
124
169
  /**
125
170
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -202,6 +247,25 @@ const EXIT_CODES = {
202
247
  // never fatal on its own. Grouped for a greppable exit code.
203
248
  [ErrorCode.McpUntrusted]: 16,
204
249
  [ErrorCode.McpConnect]: 16,
250
+ // Web search + fetch (C.20). A missing provider, a search/fetch failure, and an
251
+ // SSRF-blocked host all surface inside a tool result (the agent reads and
252
+ // adapts) and only exit the process if thrown directly. Grouped for a greppable
253
+ // exit code.
254
+ [ErrorCode.WebUnavailable]: 17,
255
+ [ErrorCode.WebSearch]: 17,
256
+ [ErrorCode.WebFetch]: 17,
257
+ [ErrorCode.WebBlockedHost]: 17,
258
+ // Multi-repo / workspace (C.26). Declaration-time root-set problems
259
+ // (unknown/ambiguous/overlap/add-refused) and multi-root rollback failures
260
+ // (incomplete/partial) share a greppable exit code. A cross-root *path* is NOT
261
+ // here — it reuses CRUXY_E_PATH_ESCAPE (a cross-root path is an escape from the
262
+ // acting root; a distinct code would wrongly imply "less bad").
263
+ [ErrorCode.RootUnknown]: 18,
264
+ [ErrorCode.RootAmbiguous]: 18,
265
+ [ErrorCode.RootOverlap]: 18,
266
+ [ErrorCode.RootAddRefused]: 18,
267
+ [ErrorCode.CheckpointSetIncomplete]: 18,
268
+ [ErrorCode.CheckpointSetPartial]: 18,
205
269
  };
206
270
  /** The process exit code for an error code (defaults to 1 for safety). */
207
271
  export function exitCodeFor(code) {
@@ -151,7 +151,10 @@ export function buildRunArgs(policy, container, command) {
151
151
  "--tmpfs",
152
152
  `${policy.tmpfs}:rw,nosuid,nodev,size=64m`, // … except an in-memory tmp
153
153
  "-v",
154
- mountSpec(policy.workdir), // ONLY the workdir, read-write
154
+ mountSpec(policy.workdir), // the command's own root, read-write
155
+ // Other declared roots (C.26, R5): read-only unless a per-command escalation
156
+ // flipped one to rw. Ambient cross-root write is never the default.
157
+ ...(policy.siblingRoots ?? []).flatMap((m) => ["-v", mountSpec(m)]),
155
158
  ...policy.mounts.flatMap((m) => ["-v", mountSpec(m)]),
156
159
  "-w",
157
160
  policy.workdir.target,
@@ -5,8 +5,12 @@ import type { IsolationPolicy } from "./types.js";
5
5
  * {@link IsolationPolicy}. This is where the security posture is decided, and
6
6
  * every default here is deny/minimal:
7
7
  *
8
- * - the ONLY read-write mount is the project workdir (at its identical absolute
9
- * path, so paths stay coherent with the host and the C.32 checkpoint);
8
+ * - the DEFAULT read-write mount is the command's own project workdir (at its
9
+ * identical absolute path, so paths stay coherent with the host and the C.32
10
+ * checkpoint);
11
+ * - other declared roots in a multi-repo session (C.26, R5) are mounted READ-ONLY
12
+ * — readable for legit cross-repo builds, never writable unless an explicit
13
+ * per-command escalation names that root in `writableRoots`;
10
14
  * - extra mounts come solely from `sandbox.mounts` (explicit by construction),
11
15
  * and a mount of the docker socket, the cruxy home, or the user's home root
12
16
  * is rejected — those are the escape hatches we refuse to open;
@@ -14,4 +18,9 @@ import type { IsolationPolicy } from "./types.js";
14
18
  * writable and never left root-owned;
15
19
  * - network defaults to `none`; any widening can only come from explicit config.
16
20
  */
17
- export declare function buildPolicy(cfg: SandboxConfig, cwd: string): IsolationPolicy;
21
+ export declare function buildPolicy(cfg: SandboxConfig, cwd: string, opts?: {
22
+ /** Absolute paths of the OTHER declared roots (this command's siblings). */
23
+ siblingRoots?: readonly string[];
24
+ /** Sibling roots that got an approved cross-root-write escalation (R5). */
25
+ writableRoots?: readonly string[];
26
+ }): IsolationPolicy;
@@ -7,8 +7,12 @@ import { globalDir } from "../config/paths.js";
7
7
  * {@link IsolationPolicy}. This is where the security posture is decided, and
8
8
  * every default here is deny/minimal:
9
9
  *
10
- * - the ONLY read-write mount is the project workdir (at its identical absolute
11
- * path, so paths stay coherent with the host and the C.32 checkpoint);
10
+ * - the DEFAULT read-write mount is the command's own project workdir (at its
11
+ * identical absolute path, so paths stay coherent with the host and the C.32
12
+ * checkpoint);
13
+ * - other declared roots in a multi-repo session (C.26, R5) are mounted READ-ONLY
14
+ * — readable for legit cross-repo builds, never writable unless an explicit
15
+ * per-command escalation names that root in `writableRoots`;
12
16
  * - extra mounts come solely from `sandbox.mounts` (explicit by construction),
13
17
  * and a mount of the docker socket, the cruxy home, or the user's home root
14
18
  * is rejected — those are the escape hatches we refuse to open;
@@ -16,12 +20,21 @@ import { globalDir } from "../config/paths.js";
16
20
  * writable and never left root-owned;
17
21
  * - network defaults to `none`; any widening can only come from explicit config.
18
22
  */
19
- export function buildPolicy(cfg, cwd) {
23
+ export function buildPolicy(cfg, cwd, opts = {}) {
20
24
  const workdir = {
21
25
  source: cwd,
22
26
  target: cwd,
23
27
  readonly: false,
24
28
  };
29
+ const writable = new Set((opts.writableRoots ?? []).map((r) => resolvePath(r)));
30
+ const siblingRoots = (opts.siblingRoots ?? [])
31
+ .map((r) => resolvePath(r))
32
+ .filter((r) => r !== resolvePath(cwd)) // the workdir is never a sibling
33
+ .map((source) => ({
34
+ source,
35
+ target: source, // identical path, like the workdir, for path coherence
36
+ readonly: !writable.has(source), // RO unless explicitly escalated (R5)
37
+ }));
25
38
  return {
26
39
  image: cfg.image,
27
40
  network: cfg.network,
@@ -30,6 +43,7 @@ export function buildPolicy(cfg, cwd) {
30
43
  pids: cfg.pids,
31
44
  cpus: cfg.cpus,
32
45
  workdir,
46
+ siblingRoots,
33
47
  mounts: cfg.mounts.map((spec) => parseMount(spec, cwd)),
34
48
  tmpfs: "/tmp",
35
49
  };
@@ -43,8 +43,17 @@ export interface IsolationPolicy {
43
43
  readonly pids: number;
44
44
  /** CPU cap (`--cpus`, fractional allowed). */
45
45
  readonly cpus: number;
46
- /** The project workdir, mounted read-write at the identical absolute path. */
46
+ /** The command's OWN workspace root, mounted read-write at its absolute path. */
47
47
  readonly workdir: BindMount;
48
+ /**
49
+ * The OTHER declared workspace roots in a multi-repo session (C.26, R5). Each
50
+ * is mounted **read-only** so a legit build can *read* a sibling (e.g. generated
51
+ * client types) — but never write it. A sibling flips to read-write ONLY when an
52
+ * explicit per-command cross-root-write escalation was approved for that exact
53
+ * root; ambient cross-root write authority is never the default. Empty in a
54
+ * single-root session (identical to the pre-C.26 posture).
55
+ */
56
+ readonly siblingRoots?: readonly BindMount[];
48
57
  /** Extra explicit mounts beyond the workdir (from `sandbox.mounts`). */
49
58
  readonly mounts: readonly BindMount[];
50
59
  /** Writable in-memory tmp mount point; the rest of the root fs is read-only. */
@@ -1,26 +1,19 @@
1
- import { CruxyError } from "../../errors/index.js";
1
+ import { PathEscapeError } 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. The confinement kernel now lives in
5
+ * `src/workspace` so multi-root (C.26) and single-root callers share ONE
6
+ * implementation. This module keeps the single-root `resolveInRoot` entry point
7
+ * (and re-exports {@link PathEscapeError}) so existing call sites are unchanged:
8
+ * they confine to `ctx.cwd`, which is the workspace's primary root.
8
9
  */
9
- export declare class PathEscapeError extends CruxyError {
10
- constructor(message: string);
11
- }
10
+ export { PathEscapeError };
12
11
  /**
13
12
  * 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.
13
+ * stays inside — the single-root funnel. Delegates to {@link confineToRoot}; see
14
+ * there for the 2-layer (lexical + symlink) confinement logic.
15
15
  *
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`).
21
- *
22
- * @returns the resolved absolute path (lexical, not realpath'd — so callers
23
- * operate on the intended location).
16
+ * @returns the resolved absolute path (lexical, not realpath'd).
24
17
  * @throws {PathEscapeError} if the path escapes the root.
25
18
  */
26
19
  export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<string>;