@celestea/tools 2.7.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. package/package.json +28 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Path canonicalization helpers for the guard.
3
+ *
4
+ * Every decision is made on the *canonical* path (`realpath`, i.e. symlinks
5
+ * resolved), never on the string the caller supplied:
6
+ * - reads/list: the canonical target must exist inside a read root; an
7
+ * unresolvable target passes through so the tool reports its own error;
8
+ * - writes: an existing file canonicalizes directly; a not-yet-created file
9
+ * canonicalizes its nearest existing ancestor and re-appends the missing
10
+ * suffix, so a new file is arbitrated by the directory it would land in.
11
+ *
12
+ * This closes `..` traversal and symlink escape at once: both are collapsed by
13
+ * canonicalization before the containment test (parity:
14
+ * `crates/tools/src/guard.rs`).
15
+ */
16
+ /** `realpath` or `null` when the path does not exist / cannot be resolved. */
17
+ export declare function canonicalExisting(target: string): string | null;
18
+ /** Resolve `target` against `workspace` (absolute targets are kept as given). */
19
+ export declare function absolutize(target: string, workspace: string): string;
20
+ /** Containment test on canonical paths (segment-aware, not string-prefix). */
21
+ export declare function isInside(child: string, root: string): boolean;
22
+ export declare function isDirectory(target: string): boolean;
23
+ /** Canonical path of an existing read/list target, or `null`. */
24
+ export declare function resolveExistingTarget(target: string, workspace: string): string | null;
25
+ /**
26
+ * Canonical path a write would land on, or null when no ancestor exists.
27
+ *
28
+ * W824 (W812 P0-1): the FINAL component must never be treated as a plain
29
+ * missing file when it is a symlink. realpath fails for a dangling link, and
30
+ * the lexical re-append below would then hand back the link's own
31
+ * (in-workspace) path while fs.writeFile follows the link out of the
32
+ * workspace. Resolve the link target first - even when that target does not
33
+ * exist yet - so containment is decided on where the bytes would actually land.
34
+ */
35
+ export declare function resolveWriteTarget(target: string, workspace: string): string | null;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Path canonicalization helpers for the guard.
3
+ *
4
+ * Every decision is made on the *canonical* path (`realpath`, i.e. symlinks
5
+ * resolved), never on the string the caller supplied:
6
+ * - reads/list: the canonical target must exist inside a read root; an
7
+ * unresolvable target passes through so the tool reports its own error;
8
+ * - writes: an existing file canonicalizes directly; a not-yet-created file
9
+ * canonicalizes its nearest existing ancestor and re-appends the missing
10
+ * suffix, so a new file is arbitrated by the directory it would land in.
11
+ *
12
+ * This closes `..` traversal and symlink escape at once: both are collapsed by
13
+ * canonicalization before the containment test (parity:
14
+ * `crates/tools/src/guard.rs`).
15
+ */
16
+ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
17
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
18
+ /** `realpath` or `null` when the path does not exist / cannot be resolved. */
19
+ export function canonicalExisting(target) {
20
+ try {
21
+ return realpathSync(target);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Resolve `target` against `workspace` (absolute targets are kept as given). */
28
+ export function absolutize(target, workspace) {
29
+ return isAbsolute(target) ? resolve(target) : resolve(workspace, target);
30
+ }
31
+ /** Containment test on canonical paths (segment-aware, not string-prefix). */
32
+ export function isInside(child, root) {
33
+ if (child === root)
34
+ return true;
35
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
36
+ return child.startsWith(prefix);
37
+ }
38
+ export function isDirectory(target) {
39
+ try {
40
+ return statSync(target).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** Canonical path of an existing read/list target, or `null`. */
47
+ export function resolveExistingTarget(target, workspace) {
48
+ return canonicalExisting(absolutize(target, workspace));
49
+ }
50
+ /**
51
+ * The target of a symlink at p (resolved against its own directory), or null
52
+ * when p is missing / not a symlink. lstat does NOT follow the final component,
53
+ * so a DANGLING link still reports as a link here.
54
+ */
55
+ function symlinkTarget(p) {
56
+ try {
57
+ if (!lstatSync(p).isSymbolicLink())
58
+ return null;
59
+ const link = readlinkSync(p);
60
+ return isAbsolute(link) ? resolve(link) : resolve(dirname(p), link);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Canonical path a write would land on, or null when no ancestor exists.
68
+ *
69
+ * W824 (W812 P0-1): the FINAL component must never be treated as a plain
70
+ * missing file when it is a symlink. realpath fails for a dangling link, and
71
+ * the lexical re-append below would then hand back the link's own
72
+ * (in-workspace) path while fs.writeFile follows the link out of the
73
+ * workspace. Resolve the link target first - even when that target does not
74
+ * exist yet - so containment is decided on where the bytes would actually land.
75
+ */
76
+ export function resolveWriteTarget(target, workspace) {
77
+ let absolute = absolutize(target, workspace);
78
+ // Walk the final component through any symlink chain (bounded: ELOOP parity).
79
+ for (let hops = 0; hops < 40; hops += 1) {
80
+ const direct = canonicalExisting(absolute);
81
+ if (direct !== null)
82
+ return direct;
83
+ const link = symlinkTarget(absolute);
84
+ if (link === null)
85
+ break;
86
+ absolute = link;
87
+ }
88
+ const missing = [];
89
+ let current = absolute;
90
+ for (;;) {
91
+ const parent = dirname(current);
92
+ if (parent === current)
93
+ return null;
94
+ missing.push(basename(current));
95
+ const canonicalParent = canonicalExisting(parent);
96
+ if (canonicalParent !== null)
97
+ return resolve(canonicalParent, ...missing.reverse());
98
+ current = parent;
99
+ }
100
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `http_request` error contract (`crates/tools/src/http.rs`).
3
+ *
4
+ * Transport failures are *categorized*, never prose-only:
5
+ * `timeout | dns | connect | redirect | invalid_url | invalid_arg |
6
+ * target_forbidden` — so callers and agents branch on the code. An HTTP error
7
+ * status is NOT a failure: it comes back in `status` for the caller to judge.
8
+ */
9
+ import { ToolFailure } from "../tool-failure.js";
10
+ export declare const HTTP_ERROR_PREFIX = "http_request";
11
+ /** `http_request: code=<code> msg=<message>` (legacy `contract_err` shape). */
12
+ export declare function httpFailure(code: string, message: string): ToolFailure;
13
+ /** Marker thrown by the transport so classification can see *why* it failed. */
14
+ export declare class TransportError extends Error {
15
+ readonly timedOut: boolean;
16
+ readonly cause: unknown;
17
+ constructor(cause: unknown, timedOut: boolean);
18
+ }
19
+ /**
20
+ * Categorize a transport failure. DNS vs connect refusal is separated with a
21
+ * best-effort lookup: a name that cannot resolve at all is `dns`, otherwise the
22
+ * connection itself failed (`connect`).
23
+ */
24
+ export declare function classifyTransportError(e: unknown, host: string): Promise<ToolFailure>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `http_request` error contract (`crates/tools/src/http.rs`).
3
+ *
4
+ * Transport failures are *categorized*, never prose-only:
5
+ * `timeout | dns | connect | redirect | invalid_url | invalid_arg |
6
+ * target_forbidden` — so callers and agents branch on the code. An HTTP error
7
+ * status is NOT a failure: it comes back in `status` for the caller to judge.
8
+ */
9
+ import { lookup } from "node:dns/promises";
10
+ import { ToolFailure } from "../tool-failure.js";
11
+ export const HTTP_ERROR_PREFIX = "http_request";
12
+ const DNS_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "EAI_NODATA"]);
13
+ const CONNECT_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE", "ETIMEDOUT"]);
14
+ /** `http_request: code=<code> msg=<message>` (legacy `contract_err` shape). */
15
+ export function httpFailure(code, message) {
16
+ return new ToolFailure(code, `${HTTP_ERROR_PREFIX}: code=${code} msg=${message}`);
17
+ }
18
+ /** Marker thrown by the transport so classification can see *why* it failed. */
19
+ export class TransportError extends Error {
20
+ timedOut;
21
+ cause;
22
+ constructor(cause, timedOut) {
23
+ super(cause instanceof Error ? cause.message : String(cause));
24
+ this.name = "TransportError";
25
+ this.cause = cause;
26
+ this.timedOut = timedOut;
27
+ }
28
+ }
29
+ function errnoOf(e) {
30
+ if (typeof e !== "object" || e === null)
31
+ return null;
32
+ const code = e.code;
33
+ return typeof code === "string" ? code : null;
34
+ }
35
+ /**
36
+ * Categorize a transport failure. DNS vs connect refusal is separated with a
37
+ * best-effort lookup: a name that cannot resolve at all is `dns`, otherwise the
38
+ * connection itself failed (`connect`).
39
+ */
40
+ export async function classifyTransportError(e, host) {
41
+ const timedOut = e instanceof TransportError && e.timedOut;
42
+ const cause = e instanceof TransportError ? e.cause : e;
43
+ const detail = cause instanceof Error ? cause.message : String(cause);
44
+ if (timedOut)
45
+ return httpFailure("timeout", detail);
46
+ const errno = errnoOf(cause);
47
+ if (errno !== null && DNS_CODES.has(errno))
48
+ return httpFailure("dns", detail);
49
+ if (errno !== null && CONNECT_CODES.has(errno))
50
+ return httpFailure("connect", detail);
51
+ const resolved = await resolves(host);
52
+ return httpFailure(resolved ? "connect" : "dns", detail);
53
+ }
54
+ async function resolves(host) {
55
+ if (host === "")
56
+ return false;
57
+ try {
58
+ const addresses = await lookup(host, { all: true });
59
+ return addresses.length > 0;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Header handling for `http_request`: a validated request-header set and the
3
+ * response-header subset echoed back to the caller (legacy `HEADER_SUBSET`).
4
+ *
5
+ * Request headers are validated once and re-applied on every redirect hop;
6
+ * response headers are filtered to the subset that actually carries protocol
7
+ * information (blindly echoing `set-cookie` or auth headers would leak
8
+ * credentials into the transcript).
9
+ */
10
+ import type { IncomingHttpHeaders } from "node:http";
11
+ /** Response headers echoed back to the caller (values joined with ", "). */
12
+ export declare const HEADER_SUBSET: readonly string[];
13
+ export type HeaderPairs = ReadonlyArray<readonly [string, string]>;
14
+ /** Validate `{name: value}` request headers (throws `invalid_arg`). */
15
+ export declare function validateHeaderPairs(value: unknown): HeaderPairs;
16
+ /** The subset of response headers the tool reports, in contract order. */
17
+ export declare function pickHeaders(raw: IncomingHttpHeaders): Record<string, string>;
18
+ /** `Content-Length`, when the server sent a usable one. */
19
+ export declare function headerLookup(raw: IncomingHttpHeaders, name: string): string | null;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Header handling for `http_request`: a validated request-header set and the
3
+ * response-header subset echoed back to the caller (legacy `HEADER_SUBSET`).
4
+ *
5
+ * Request headers are validated once and re-applied on every redirect hop;
6
+ * response headers are filtered to the subset that actually carries protocol
7
+ * information (blindly echoing `set-cookie` or auth headers would leak
8
+ * credentials into the transcript).
9
+ */
10
+ import { isPlainObject } from "../schema.js";
11
+ import { httpFailure } from "./errors.js";
12
+ /** Response headers echoed back to the caller (values joined with ", "). */
13
+ export const HEADER_SUBSET = [
14
+ "content-type",
15
+ "content-length",
16
+ "content-encoding",
17
+ "cache-control",
18
+ "etag",
19
+ "last-modified",
20
+ "location",
21
+ "server",
22
+ "www-authenticate",
23
+ "retry-after",
24
+ "date",
25
+ ];
26
+ const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
27
+ /** Validate `{name: value}` request headers (throws `invalid_arg`). */
28
+ export function validateHeaderPairs(value) {
29
+ if (value === undefined)
30
+ return [];
31
+ if (!isPlainObject(value))
32
+ throw httpFailure("invalid_arg", "'headers' must be an object of strings");
33
+ const pairs = [];
34
+ for (const [name, raw] of Object.entries(value)) {
35
+ if (typeof raw !== "string")
36
+ throw httpFailure("invalid_arg", `header '${name}' value must be a string`);
37
+ if (!HEADER_NAME.test(name))
38
+ throw httpFailure("invalid_arg", `bad header name '${name}'`);
39
+ if (/[\r\n]/.test(raw))
40
+ throw httpFailure("invalid_arg", `header '${name}' value must not contain CR/LF`);
41
+ pairs.push([name, raw]);
42
+ }
43
+ return pairs;
44
+ }
45
+ /** The subset of response headers the tool reports, in contract order. */
46
+ export function pickHeaders(raw) {
47
+ const out = {};
48
+ for (const name of HEADER_SUBSET) {
49
+ const value = raw[name];
50
+ if (value === undefined)
51
+ continue;
52
+ out[name] = Array.isArray(value) ? value.join(", ") : value;
53
+ }
54
+ return out;
55
+ }
56
+ /** `Content-Length`, when the server sent a usable one. */
57
+ export function headerLookup(raw, name) {
58
+ const value = raw[name];
59
+ if (value === undefined)
60
+ return null;
61
+ return Array.isArray(value) ? (value[0] ?? null) : value;
62
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Bounded, policy-checked redirect following (`crates/tools/src/http.rs`).
3
+ *
4
+ * The tool always follows redirects itself instead of letting the transport do
5
+ * it, for one security reason: every hop is re-authorized against the SSRF
6
+ * policy, so a permitted host cannot bounce the request into a denied network.
7
+ * 301/302/303 degrade to `GET` without a body (parity); the chain is
8
+ * capped at [MAX_REDIRECT_HOPS], and a non-http(s) hop target is rejected.
9
+ *
10
+ * **Authorize then PIN (W738 P1)**: the check and the connect are one step. The
11
+ * policy call returns the addresses it approved ([HttpTargetPolicy.resolveChecked])
12
+ * and those exact addresses are handed to the transport, which connects to them
13
+ * without resolving again — and it is repeated for EVERY hop. A host name whose
14
+ * DNS answer changes between the check and the connect (rebinding) therefore
15
+ * cannot be used to reach an address the policy refused.
16
+ */
17
+ import type { HeaderPairs } from "./headers.js";
18
+ import type { HttpTargetPolicy } from "./ssrf.js";
19
+ import { type TransportResult } from "./transport.js";
20
+ /** Redirect hops the tool follows before failing with `code=redirect`. */
21
+ export declare const MAX_REDIRECT_HOPS = 5;
22
+ export interface FetchOptions {
23
+ method: string;
24
+ headers: HeaderPairs;
25
+ body: string | null;
26
+ timeoutMs: number;
27
+ maxBodyBytes: number;
28
+ policy: HttpTargetPolicy;
29
+ }
30
+ /** Follow up to [MAX_REDIRECT_HOPS] redirects; returns the final response. */
31
+ export declare function fetchWithPolicy(url: URL, options: FetchOptions): Promise<TransportResult>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Bounded, policy-checked redirect following (`crates/tools/src/http.rs`).
3
+ *
4
+ * The tool always follows redirects itself instead of letting the transport do
5
+ * it, for one security reason: every hop is re-authorized against the SSRF
6
+ * policy, so a permitted host cannot bounce the request into a denied network.
7
+ * 301/302/303 degrade to `GET` without a body (parity); the chain is
8
+ * capped at [MAX_REDIRECT_HOPS], and a non-http(s) hop target is rejected.
9
+ *
10
+ * **Authorize then PIN (W738 P1)**: the check and the connect are one step. The
11
+ * policy call returns the addresses it approved ([HttpTargetPolicy.resolveChecked])
12
+ * and those exact addresses are handed to the transport, which connects to them
13
+ * without resolving again — and it is repeated for EVERY hop. A host name whose
14
+ * DNS answer changes between the check and the connect (rebinding) therefore
15
+ * cannot be used to reach an address the policy refused.
16
+ */
17
+ import { classifyTransportError, httpFailure } from "./errors.js";
18
+ import { requestOnce } from "./transport.js";
19
+ /** Redirect hops the tool follows before failing with `code=redirect`. */
20
+ export const MAX_REDIRECT_HOPS = 5;
21
+ /** Follow up to [MAX_REDIRECT_HOPS] redirects; returns the final response. */
22
+ export async function fetchWithPolicy(url, options) {
23
+ let current = url;
24
+ let method = options.method;
25
+ let body = options.body;
26
+ for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop += 1) {
27
+ const checked = await options.policy.resolveChecked(current.toString());
28
+ if (checked.reason !== null)
29
+ throw httpFailure("target_forbidden", checked.reason);
30
+ const result = await sendOnce(current, method, body, options, checked.ips);
31
+ if (!isRedirect(result.status) || result.location === null)
32
+ return result;
33
+ if (hop === MAX_REDIRECT_HOPS) {
34
+ throw httpFailure("redirect", `redirect chain longer than ${MAX_REDIRECT_HOPS} hops`);
35
+ }
36
+ const next = nextHop(current, result.location);
37
+ if (result.status === 301 || result.status === 302 || result.status === 303) {
38
+ method = "GET";
39
+ body = null;
40
+ }
41
+ current = next;
42
+ }
43
+ throw httpFailure("redirect", `redirect chain longer than ${MAX_REDIRECT_HOPS} hops`);
44
+ }
45
+ async function sendOnce(url, method, body, options, pinnedIps) {
46
+ try {
47
+ return await requestOnce({
48
+ url,
49
+ method,
50
+ headers: options.headers,
51
+ body,
52
+ timeoutMs: options.timeoutMs,
53
+ maxBodyBytes: options.maxBodyBytes,
54
+ pinnedIps,
55
+ });
56
+ }
57
+ catch (e) {
58
+ throw await classifyTransportError(e, url.hostname);
59
+ }
60
+ }
61
+ function nextHop(current, location) {
62
+ let next;
63
+ try {
64
+ next = new URL(location, current);
65
+ }
66
+ catch {
67
+ throw httpFailure("redirect", `unparseable redirect location '${location}'`);
68
+ }
69
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
70
+ throw httpFailure("invalid_url", `redirect to scheme '${next.protocol.replace(":", "")}' not allowed`);
71
+ }
72
+ return next;
73
+ }
74
+ function isRedirect(status) {
75
+ return status >= 300 && status < 400;
76
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SSRF target policy for `http_request` (`crates/tools/src/http.rs`, W249 P0-3).
3
+ *
4
+ * `CELESTEA_HTTP_ALLOW` / `CELESTEA_HTTP_DENY` carry comma-separated IP/CIDR
5
+ * entries. Both unset ⇒ the policy is inactive and every target is allowed
6
+ * (status quo, surfaced as `active === false` so the caller can decide to log
7
+ * it). When a policy is active, EVERY resolved IP of the target — and of every
8
+ * redirect hop — must pass it (`allow` is a whitelist when non-empty, `deny`
9
+ * always wins).
10
+ *
11
+ * **Fail closed**: a malformed entry makes the policy deny everything until the
12
+ * operator fixes the configuration — a typo can never silently widen access.
13
+ *
14
+ * **No check-then-use (W738 P1)**: [HttpTargetPolicy.checkUrl] is a verdict only.
15
+ * The *authorizing* call is [HttpTargetPolicy.resolveChecked], which returns the
16
+ * exact addresses it approved so the transport can PIN them: between the check
17
+ * and the connect there is no second `getaddrinfo`, so a host name that answers
18
+ * with a public address during the check and with `127.0.0.1`/`169.254.169.254`
19
+ * at connect time (DNS rebinding) can no longer reach an address the policy
20
+ * refused. Never connect by name after a check.
21
+ *
22
+ * W516 (session grants): a host may pass an [SsrfGrantView] with the session's
23
+ * `net_hosts` entries. They are UNIONed into the allow side only, and only when
24
+ * the env policy is active: neither the deny list nor the fail-closed verdict
25
+ * can be reached by a grant, and an inactive policy (both env vars unset) stays
26
+ * inactive — a grant must never *tighten* a deployment into a whitelist
27
+ * (`netHostsIneffective` reports exactly that case to the host's audit).
28
+ */
29
+ export declare const ENV_HTTP_ALLOW = "CELESTEA_HTTP_ALLOW";
30
+ export declare const ENV_HTTP_DENY = "CELESTEA_HTTP_DENY";
31
+ export interface IpRange {
32
+ /** 4 or 6 — an IPv4 range never matches an IPv6 address. */
33
+ family: 4 | 6;
34
+ base: bigint;
35
+ prefix: number;
36
+ }
37
+ /** Parse `1.2.3.4`, `10.0.0.0/8`, `::1` or `fd00::/8` (throws on nonsense). */
38
+ export declare function parseIpRange(entry: string): IpRange;
39
+ /** Containment test on the masked network prefix. */
40
+ export declare function ipInRange(range: IpRange, ip: string): boolean;
41
+ /** Session-grant view of the SSRF policy (W516): allow-side widening only. */
42
+ export interface SsrfGrantView {
43
+ /** `net_hosts` scope: IP/CIDR entries and/or host names. */
44
+ netHosts?: readonly string[];
45
+ }
46
+ /**
47
+ * Host -> addresses resolver. Returns the address list, or a failure reason
48
+ * string (which becomes the denial reason). Default: `node:dns/promises`
49
+ * `lookup` (all families, verbatim order).
50
+ */
51
+ export type HostResolver = (host: string, port: number) => Promise<readonly string[] | string>;
52
+ /** Injectable seams of the policy (tests, custom DNS, W738 pinning harnesses). */
53
+ export interface HttpTargetPolicyOptions {
54
+ resolver?: HostResolver;
55
+ }
56
+ /** Verdict + the exact approved addresses of ONE target. */
57
+ export interface CheckedTarget {
58
+ /** `null` when the target is authorized, else the denial reason. */
59
+ reason: string | null;
60
+ /** Approved addresses to pin; empty whenever `reason !== null`. */
61
+ ips: string[];
62
+ }
63
+ export declare class HttpTargetPolicy {
64
+ private readonly allow;
65
+ private readonly deny;
66
+ private readonly failClosed;
67
+ private readonly hostAllow;
68
+ private readonly hostsIneffective;
69
+ private readonly resolver;
70
+ private constructor();
71
+ /** Parse the two lists; a malformed entry throws (see [fromEnv] for env use). */
72
+ static parse(allowSpec?: string | null, denySpec?: string | null, options?: HttpTargetPolicyOptions): HttpTargetPolicy;
73
+ /**
74
+ * Policy from the environment; malformed config ⇒ fail closed. Session grants
75
+ * are merged on the allow side ONLY (see the module docs): the env policy must
76
+ * be active for them to count at all.
77
+ */
78
+ static fromEnv(env?: NodeJS.ProcessEnv, grants?: SsrfGrantView, options?: HttpTargetPolicyOptions): HttpTargetPolicy;
79
+ /** Whether the policy constrains targets at all. */
80
+ get active(): boolean;
81
+ /** Allow/deny entry counts (diagnostics). */
82
+ get sizes(): {
83
+ allow: number;
84
+ deny: number;
85
+ failClosed: boolean;
86
+ };
87
+ /** true when `net_hosts` grants were dropped because the env policy is off. */
88
+ get netHostsIneffective(): boolean;
89
+ /**
90
+ * Verdict only (`null` = authorized). NEVER sufficient on its own: the caller
91
+ * must connect to the addresses returned by [resolveChecked] instead of
92
+ * letting the transport resolve the name again (W738 check-then-use).
93
+ */
94
+ checkUrl(url: string): Promise<string | null>;
95
+ /**
96
+ * Authorize a target and return the addresses it approved. `reason !== null`
97
+ * ⇒ `ips` is empty and nothing may be connected; `reason === null` ⇒ `ips` is
98
+ * non-empty and is the COMPLETE set of addresses the caller may use (pin them
99
+ * on every hop).
100
+ */
101
+ resolveChecked(url: string): Promise<CheckedTarget>;
102
+ private ipAllowed;
103
+ }
104
+ /** Every address a target host resolves to, or a failure reason string. */
105
+ export declare function resolveTargets(host: string, port: number): Promise<string[] | string>;