@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
package/dist/config/schema.js
CHANGED
|
@@ -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({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -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). */
|
package/dist/errors/types.js
CHANGED
|
@@ -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), //
|
|
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,
|
package/dist/sandbox/policy.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
* path, so paths stay coherent with the host and the C.32
|
|
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
|
|
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;
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -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
|
|
11
|
-
* path, so paths stay coherent with the host and the C.32
|
|
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
|
};
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
1
|
+
import { PathEscapeError } from "../../workspace/index.js";
|
|
2
2
|
import type { ToolContext } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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>;
|