@crewhaus/tool-proc 0.7.0 → 0.8.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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * What WaitForPort may dial (C144).
3
+ *
4
+ * WaitForPort is read-only, so plan and auto mode run it unasked, and it
5
+ * used to connect() to any host a call named: the cloud metadata address,
6
+ * the LAN, any port on loopback in an inet_aton spelling — a port-scan
7
+ * oracle, and a DNS query for any name the model chose. Its purpose is
8
+ * waiting for a server the harness just started, which is loopback. So
9
+ * loopback is allowed without configuration, and anything else only when
10
+ * the operator lists it in `tool_config.proc.wait_for_port_hosts`.
11
+ *
12
+ * Addresses are classified NUMERICALLY by the repository's shared
13
+ * private-address classifier, carried here as the byte-identical block
14
+ * apps/cli/src/tool-registry.test.ts hashes across every copy (so
15
+ * `0x7f.1`, `2130706433` and `::ffff:127.0.0.1` are all loopback, and
16
+ * `::ffff:169.254.169.254` is link-local).
17
+ */
18
+ // BEGIN SYNCHRONISED BLOCK — private-address classifier
19
+ //
20
+ // This block is BYTE-IDENTICAL across every package that guards an outbound
21
+ // request. Do not edit one copy: `apps/cli/src/tool-registry.test.ts` hashes
22
+ // them all and fails if any differs, and it asserts how many it found, because
23
+ // a copy-scanning guard that matches nothing reports green.
24
+ //
25
+ // It exists in copies rather than a shared package because these files are
26
+ // otherwise independent per-package networking layers; a guard proving the
27
+ // copies are identical is cheaper and safer than the import graph a shared
28
+ // package would need across `crawler`, `computer-use-driver` and ten tools.
29
+ //
30
+ // WHY IT PARSES INSTEAD OF MATCHING TEXT. Six copies were confirmed
31
+ // exploitable on 2026-09-18 because they compared address STRINGS. The WHATWG
32
+ // URL parser rewrites `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, so a
33
+ // text check never sees the spelling it was written for; and `64:ff9b::a9fe:a9fe`
34
+ // IS 169.254.169.254 on any network running DNS64/NAT64. Parsing numerically and
35
+ // recursing into the embedded IPv4 is the only form that holds.
36
+ //
37
+ // WHAT IT CANNOT DO. RFC 6052 lets an operator choose any Network-Specific
38
+ // Prefix for NAT64, so an embedded IPv4 behind an arbitrary NSP is undecidable
39
+ // from the address alone. That case is configuration, and the callers that need
40
+ // it resolve the host and re-check the ANSWER before dialling.
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * Canonicalise an IPv4 literal.
44
+ *
45
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
46
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
47
+ * only understands dotted-decimal waves every one of them through. Returns
48
+ * `null` when the string is not an IPv4 literal at all.
49
+ */
50
+ export function normalizeIpv4(raw) {
51
+ const trimmed = raw.trim();
52
+ if (trimmed === "" || /[^0-9a-fA-FxX.]/.test(trimmed))
53
+ return null;
54
+ const parts = trimmed.split(".");
55
+ if (parts.length === 0 || parts.length > 4)
56
+ return null;
57
+ const values = [];
58
+ for (const part of parts) {
59
+ if (part === "")
60
+ return null;
61
+ let value;
62
+ if (/^0[xX][0-9a-fA-F]+$/.test(part))
63
+ value = Number.parseInt(part.slice(2), 16);
64
+ else if (/^0[0-7]+$/.test(part))
65
+ value = Number.parseInt(part.slice(1), 8);
66
+ else if (/^\d+$/.test(part))
67
+ value = Number.parseInt(part, 10);
68
+ else
69
+ return null;
70
+ if (!Number.isFinite(value) || value < 0)
71
+ return null;
72
+ values.push(value);
73
+ }
74
+ // The short forms pack the remaining octets into the last part: `127.1` is
75
+ // 127.0.0.1, not 127.1.0.0. Getting this backwards is how a bypass survives.
76
+ const last = values[values.length - 1];
77
+ const leading = values.slice(0, -1);
78
+ if (leading.some((v) => v > 255))
79
+ return null;
80
+ const remaining = 4 - leading.length;
81
+ if (last >= 2 ** (8 * remaining))
82
+ return null;
83
+ const octets = [...leading];
84
+ for (let i = remaining - 1; i >= 0; i--)
85
+ octets.push((last >>> (8 * i)) & 0xff);
86
+ return octets.join(".");
87
+ }
88
+ /**
89
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
90
+ * is not one.
91
+ *
92
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
93
+ * has many spellings and the one a check was written against is rarely the one
94
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
95
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
96
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
97
+ * first means the prefix tests below are arithmetic, and spelling stops
98
+ * mattering.
99
+ */
100
+ export function parseIpv6(raw) {
101
+ let text = raw.trim().toLowerCase();
102
+ if (text.startsWith("["))
103
+ text = text.slice(1);
104
+ if (text.endsWith("]"))
105
+ text = text.slice(0, -1);
106
+ const zone = text.indexOf("%"); // fe80::1%eth0
107
+ if (zone !== -1)
108
+ text = text.slice(0, zone);
109
+ if (!text.includes(":"))
110
+ return null;
111
+ // A trailing dotted quad is the last two groups written in IPv4. Require all
112
+ // three dots: without that, `::1` parses as a one-part inet_aton address and
113
+ // takes a path that has nothing to do with what was written.
114
+ const dotted = /^(.*:)(\d+(?:\.\d+){3})$/.exec(text);
115
+ if (dotted !== null) {
116
+ const quad = normalizeIpv4(dotted[2]);
117
+ if (quad === null)
118
+ return null;
119
+ const o = quad.split(".").map((n) => Number.parseInt(n, 10));
120
+ const hi = (o[0] << 8) | o[1];
121
+ const lo = (o[2] << 8) | o[3];
122
+ text = `${dotted[1]}${hi.toString(16)}:${lo.toString(16)}`;
123
+ }
124
+ const halves = text.split("::");
125
+ if (halves.length > 2)
126
+ return null;
127
+ const pieces = (part) => (part === "" ? [] : part.split(":"));
128
+ const head = pieces(halves[0]);
129
+ const tail = halves.length === 2 ? pieces(halves[1]) : [];
130
+ if (halves.length === 1 && head.length !== 8)
131
+ return null;
132
+ if (head.length + tail.length > 8)
133
+ return null;
134
+ const groups = [];
135
+ for (const piece of head) {
136
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
137
+ return null;
138
+ groups.push(Number.parseInt(piece, 16));
139
+ }
140
+ for (let i = head.length + tail.length; i < 8; i++)
141
+ groups.push(0);
142
+ for (const piece of tail) {
143
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
144
+ return null;
145
+ groups.push(Number.parseInt(piece, 16));
146
+ }
147
+ return groups.length === 8 ? groups : null;
148
+ }
149
+ /**
150
+ * The IPv4 address an IPv6 address carries, when it carries one.
151
+ *
152
+ * Every transition mechanism embeds a v4 address somewhere, and every one of
153
+ * them is a way to reach a v4 destination while wearing a v6 spelling that no
154
+ * v4 range check looks at. The NAT64 well-known prefix is the sharpest: a
155
+ * DNS64 resolver answers an IPv4-only name with `64:ff9b::<the v4>`, so
156
+ * `64:ff9b::a9fe:a9fe` IS the metadata service on any network that runs one.
157
+ */
158
+ function embeddedIpv4(g) {
159
+ const quad = (hi, lo) => `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
160
+ const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
161
+ const last = quad(g[6], g[7]);
162
+ if (zeros(0, 5) && g[5] === 0xffff)
163
+ return last; // ::ffff:0:0/96, IPv4-mapped
164
+ if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0)
165
+ return last; // ::ffff:0:0:0/96, translated
166
+ if (g[0] === 0x64 && g[1] === 0xff9b)
167
+ return last; // 64:ff9b::/96 and 64:ff9b:1::/48, NAT64
168
+ if (g[0] === 0x2002)
169
+ return quad(g[1], g[2]); // 2002::/16, 6to4
170
+ // ::a.b.c.d, IPv4-compatible: deprecated, still routed by some stacks, and
171
+ // not to be confused with `::` or `::1`, which are handled before this.
172
+ if (zeros(0, 6))
173
+ return last;
174
+ return null;
175
+ }
176
+ /** Ranges that are never a public RPC endpoint. */
177
+ export function isPrivateIp(address) {
178
+ const v4 = normalizeIpv4(address);
179
+ if (v4 !== null) {
180
+ const [a = 0, b = 0, c = 0, d = 0] = v4.split(".").map((n) => Number.parseInt(n, 10));
181
+ if (a === 0 || a === 10 || a === 127)
182
+ return true;
183
+ if (a === 169 && b === 254)
184
+ return true; // link-local, and the metadata service
185
+ if (a === 172 && b >= 16 && b <= 31)
186
+ return true;
187
+ if (a === 192 && b === 168)
188
+ return true;
189
+ if (a === 192 && b === 0 && c === 0)
190
+ return true;
191
+ if (a === 100 && b >= 64 && b <= 127)
192
+ return true; // carrier-grade NAT
193
+ if (a === 198 && (b === 18 || b === 19))
194
+ return true; // benchmarking
195
+ if (a >= 224)
196
+ return true; // multicast, reserved, broadcast
197
+ return a === 255 && b === 255 && c === 255 && d === 255;
198
+ }
199
+ const groups = parseIpv6(address);
200
+ if (groups === null)
201
+ return false;
202
+ if (groups.every((g) => g === 0))
203
+ return true; // ::, the unspecified address
204
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
205
+ return true; // ::1
206
+ const carried = embeddedIpv4(groups);
207
+ if (carried !== null)
208
+ return isPrivateIp(carried);
209
+ const head = groups[0];
210
+ if ((head & 0xfe00) === 0xfc00)
211
+ return true; // fc00::/7, unique-local
212
+ if ((head & 0xffc0) === 0xfe80)
213
+ return true; // fe80::/10, link-local
214
+ return (head & 0xff00) === 0xff00; // ff00::/8, multicast
215
+ }
216
+ // END SYNCHRONISED BLOCK
217
+ /** True for 127.0.0.0/8, ::1, and a v6 spelling that carries a 127/8 address. */
218
+ export function isLoopbackIp(address) {
219
+ const v4 = normalizeIpv4(address);
220
+ if (v4 !== null)
221
+ return v4.startsWith("127.");
222
+ const groups = parseIpv6(address);
223
+ if (groups === null)
224
+ return false;
225
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
226
+ return true;
227
+ return embeddedIpv4(groups)?.startsWith("127.") === true;
228
+ }
229
+ /**
230
+ * True for link-local addresses, where cloud metadata services live:
231
+ * 169.254.0.0/16, fe80::/10, AWS's fd00:ec2::254, and a v6 spelling that
232
+ * carries a 169.254/16 address.
233
+ */
234
+ export function isLinkLocalIp(address) {
235
+ const v4 = normalizeIpv4(address);
236
+ if (v4 !== null)
237
+ return v4.startsWith("169.254.");
238
+ const groups = parseIpv6(address);
239
+ if (groups === null)
240
+ return false;
241
+ const carried = embeddedIpv4(groups);
242
+ if (carried !== null)
243
+ return carried.startsWith("169.254.");
244
+ if ((groups[0] & 0xffc0) === 0xfe80)
245
+ return true;
246
+ const ec2 = [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254];
247
+ return groups.every((g, i) => g === ec2[i]);
248
+ }
249
+ /** The canonical spelling of an IP literal (dotted quad, or v6 as written), or null. */
250
+ export function canonicalIp(address) {
251
+ const v4 = normalizeIpv4(address);
252
+ if (v4 !== null)
253
+ return v4;
254
+ return parseIpv6(address) === null ? null : address.toLowerCase();
255
+ }
256
+ const systemLookup = async (host) => {
257
+ const { lookup } = await import("node:dns/promises");
258
+ const answers = await lookup(host, { all: true, verbatim: true });
259
+ return answers.map((a) => a.address);
260
+ };
261
+ let dnsLookup = systemLookup;
262
+ /** Test seam: replace the resolver; `undefined` restores the system one. */
263
+ export function _setDnsLookup(fn) {
264
+ dnsLookup = fn ?? systemLookup;
265
+ }
266
+ /**
267
+ * True for the unspecified address in any spelling (0.0.0.0, ::, an
268
+ * IPv4-mapped 0.0.0.0). A server that binds it prints it ("running on
269
+ * http://0.0.0.0:8000"), and dialling it reaches only this machine on Linux
270
+ * and macOS, so WaitForPort treats it as loopback and dials loopback itself,
271
+ * which also works where connecting to 0.0.0.0 does not (Windows).
272
+ */
273
+ export function unspecifiedDial(address) {
274
+ const v4 = normalizeIpv4(address);
275
+ if (v4 !== null)
276
+ return v4 === "0.0.0.0" ? "127.0.0.1" : null;
277
+ const groups = parseIpv6(address);
278
+ if (groups === null)
279
+ return null;
280
+ if (groups.every((g) => g === 0))
281
+ return "::1";
282
+ const mapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
283
+ return mapped && groups[6] === 0 && groups[7] === 0 ? "127.0.0.1" : null;
284
+ }
285
+ /**
286
+ * An IPv6 literal in the form WaitForPort dials it, or `null` when it is not
287
+ * one or may not be dialled.
288
+ *
289
+ * The form is the URL parser's (`0:0:0:0:0:ffff:127.0.0.1` is
290
+ * `::ffff:7f00:1`), which is how a permission rule reads a host too, so the
291
+ * probe goes to the address classified here and decided on there, and no
292
+ * resolver reads the string again. A spelling the URL parser does not read
293
+ * as the address {@link parseIpv6} does has no one reading, and is not
294
+ * dialled: an IPv4 part with a leading zero is octal to parseIpv6, decimal
295
+ * to macOS's resolver and invalid to the URL parser, so `::ffff:0177.0.0.1`
296
+ * was classified as loopback and reached 177.0.0.1.
297
+ */
298
+ export function ipv6Dial(address) {
299
+ const groups = parseIpv6(address);
300
+ if (groups === null)
301
+ return null;
302
+ const inner = address.trim().replace(/^\[(.*)\]$/, "$1");
303
+ let host;
304
+ try {
305
+ host = new URL(`http://[${inner}]/`).hostname.slice(1, -1);
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ const read = parseIpv6(host);
311
+ return read?.every((g, i) => g === groups[i]) ? host : null;
312
+ }
313
+ /**
314
+ * Decide what one WaitForPort call dials for `host`, or why it may not.
315
+ * Nothing here touches DNS.
316
+ *
317
+ * - `localhost`, and any IP literal that is loopback in any spelling, is
318
+ * allowed with no configuration (a literal is dialled in its canonical
319
+ * form, so `0x7f.1` dials 127.0.0.1, and an IPv6 one in the URL parser's,
320
+ * see {@link ipv6Dial}). So is the unspecified address (0.0.0.0, ::),
321
+ * dialled as 127.0.0.1 / ::1.
322
+ * - An IPv6 literal with no one reading (`::ffff:0177.0.0.1`) is refused,
323
+ * listed or not.
324
+ * - Any other host must be listed in `allowed` (the operator's
325
+ * `tool_config.proc.wait_for_port_hosts`), and is refused BEFORE any DNS
326
+ * query, so an unlisted name is not a way to send one either.
327
+ * - A listed literal is dialled in that canonical form; a listed NAME is
328
+ * resolved at probe time (`resolveListedHost`).
329
+ */
330
+ export function portTarget(host, allowed, configKey) {
331
+ const lower = host.toLowerCase();
332
+ if (lower === "localhost" || lower === "localhost.")
333
+ return { ok: true, dial: "localhost" };
334
+ let literal = normalizeIpv4(host);
335
+ if (literal === null && parseIpv6(host) !== null) {
336
+ literal = ipv6Dial(host);
337
+ if (literal === null) {
338
+ return {
339
+ ok: false,
340
+ message: `[WaitForPort error] "${host}" is an IPv6 address written in a form resolvers read as different addresses. Write it the standard way, such as ::1 or ::ffff:127.0.0.1.`,
341
+ };
342
+ }
343
+ }
344
+ if (literal !== null && isLoopbackIp(literal))
345
+ return { ok: true, dial: literal };
346
+ const unspecified = literal === null ? null : unspecifiedDial(literal);
347
+ if (unspecified !== null)
348
+ return { ok: true, dial: unspecified };
349
+ const listed = allowed.includes(lower) || (literal !== null && allowed.includes(literal));
350
+ if (!listed) {
351
+ return {
352
+ ok: false,
353
+ message: `[WaitForPort error] "${host}" is not a loopback address. WaitForPort probes loopback (localhost, 127.0.0.1, ::1) unless the operator lists the host in ${configKey}.`,
354
+ };
355
+ }
356
+ if (literal !== null)
357
+ return { ok: true, dial: literal };
358
+ return { ok: true, resolve: host };
359
+ }
360
+ /**
361
+ * Resolve a listed name for one probe. An empty answer, or a failed lookup,
362
+ * is "not accepting connections" for that probe, not a refusal: a compose
363
+ * service's name exists only once its container does, and a torn-down one
364
+ * stops resolving — which is exactly "closed". Every answer is checked: a
365
+ * name that resolves to a link-local address (the cloud metadata service) is
366
+ * refused unless that address itself is listed. The caller dials the address
367
+ * returned, and keeps dialling it for the rest of the call, so a resolver
368
+ * that answers differently later cannot move the probe.
369
+ */
370
+ export async function resolveListedHost(host, allowed, configKey) {
371
+ let answers;
372
+ try {
373
+ answers = await dnsLookup(host);
374
+ }
375
+ catch {
376
+ answers = [];
377
+ }
378
+ const first = answers[0];
379
+ if (first === undefined)
380
+ return { ok: true, unresolved: true };
381
+ for (const address of answers) {
382
+ const canonical = canonicalIp(address) ?? address.toLowerCase();
383
+ if (isLinkLocalIp(address) && !allowed.includes(canonical)) {
384
+ return {
385
+ ok: false,
386
+ message: `[WaitForPort error] "${host}" resolves to a link-local address, where cloud metadata services live; list that address itself in ${configKey} to probe it.`,
387
+ };
388
+ }
389
+ }
390
+ return { ok: true, dial: canonicalIp(first) ?? first };
391
+ }
@@ -0,0 +1,34 @@
1
+ /** The spec key a refusal names. */
2
+ export declare const ENV_REVEAL_KEY = "tool_config.proc.env_reveal";
3
+ /** The spec key WaitForPort's refusal names. */
4
+ export declare const PORT_HOSTS_KEY = "tool_config.proc.wait_for_port_hosts";
5
+ /**
6
+ * Parse `wait_for_port_hosts` (or `waitForPortHosts`): host names or IP
7
+ * literals, compared case-insensitively. Throws with a sentence naming the key.
8
+ */
9
+ export declare function parsePortHosts(config: unknown): readonly string[];
10
+ /** Parse a `tool_config.proc` block's `env_reveal`; throws with a sentence naming the key. */
11
+ export declare function parseProcConfig(config: unknown): readonly string[];
12
+ /**
13
+ * The boot registrar: `tool_config.proc`. A block under the own key of a
14
+ * tool that reads it — `tool_config.envInspect` or `tool_config.waitForPort`
15
+ * — reaches it too, when that tool is listed (the builtin table's boot rule;
16
+ * no other tool-proc tool names this registrar). Both keys are parsed before either is stored,
17
+ * so a block with one bad key changes nothing.
18
+ */
19
+ export declare function registerProcConfig(config: unknown): void;
20
+ /**
21
+ * The extra hosts ONE WaitForPort call may probe: the serving candidate's
22
+ * block when it declares one, else the boot registration. A candidate block
23
+ * that does not parse allows nothing beyond loopback.
24
+ */
25
+ export declare function portHostsFor(override: unknown): readonly string[];
26
+ /**
27
+ * The reveal list ONE call runs under: the serving candidate's block when it
28
+ * declares one (replacing the boot registration, as every package's
29
+ * per-call override does), else the boot registration. A candidate block
30
+ * that does not parse reveals nothing rather than falling back.
31
+ */
32
+ export declare function revealAllowFor(override: unknown): readonly string[];
33
+ /** Test-only: back to the default, which reveals nothing and probes loopback only. */
34
+ export declare function _resetProcConfig(): void;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `tool_config.proc` — what an operator configures for this package.
3
+ *
4
+ * `wait_for_port_hosts` (C144): the hosts WaitForPort may probe besides
5
+ * loopback — a compose service (`db`), a LAN address. With none listed it
6
+ * probes loopback only; see ./addr.
7
+ *
8
+ * `env_reveal`: the environment variables whose VALUES
9
+ * EnvInspect may show. Before 0.7.1 the tool showed any value the CALL named
10
+ * in `reveal`, so a model (or the text steering it) could read
11
+ * ANTHROPIC_API_KEY into its own context from a read-only tool that plan and
12
+ * auto mode run unasked (C052: docs-claims#6, flag-truth-4#1, security-8#3).
13
+ * Now the list comes from the operator, never from the call, and a name that
14
+ * looks like a credential is never shown even when listed
15
+ * (`@crewhaus/tool-safety/env`'s checkEnvReveal).
16
+ *
17
+ * The block reaches the package through the builtin table's boot registrar
18
+ * (`registerProcConfig`), which every emitter, `crewhaus run` and `crewhaus
19
+ * eval` call; a model pool candidate's own block replaces it for that
20
+ * candidate's calls (`ToolExecuteContext.toolConfig`).
21
+ */
22
+ import { credentialShapeOf, isEnvName } from "@crewhaus/tool-safety/env";
23
+ /** The spec key a refusal names. */
24
+ export const ENV_REVEAL_KEY = "tool_config.proc.env_reveal";
25
+ /** The spec key WaitForPort's refusal names. */
26
+ export const PORT_HOSTS_KEY = "tool_config.proc.wait_for_port_hosts";
27
+ let envReveal = [];
28
+ let portHosts = [];
29
+ /** A host WaitForPort may be named: the tool's own hostname shape. */
30
+ const HOST_SHAPE = /^[A-Za-z0-9._:-]{1,255}$/;
31
+ /**
32
+ * Parse `wait_for_port_hosts` (or `waitForPortHosts`): host names or IP
33
+ * literals, compared case-insensitively. Throws with a sentence naming the key.
34
+ */
35
+ export function parsePortHosts(config) {
36
+ if (config === undefined || config === null)
37
+ return [];
38
+ if (typeof config !== "object" || Array.isArray(config))
39
+ return [];
40
+ const block = config;
41
+ const snake = block["wait_for_port_hosts"];
42
+ const camel = block["waitForPortHosts"];
43
+ if (snake !== undefined && camel !== undefined) {
44
+ throw new Error("tool_config.proc sets both wait_for_port_hosts and waitForPortHosts; keep one");
45
+ }
46
+ const list = snake ?? camel;
47
+ if (list === undefined)
48
+ return [];
49
+ if (!Array.isArray(list) || list.some((h) => typeof h !== "string" || !HOST_SHAPE.test(h))) {
50
+ throw new Error(`${PORT_HOSTS_KEY} must be a list of host names or IP addresses (for example [db, 10.0.0.5])`);
51
+ }
52
+ return Object.freeze([...new Set(list.map((h) => h.toLowerCase()))].sort());
53
+ }
54
+ /** Parse a `tool_config.proc` block's `env_reveal`; throws with a sentence naming the key. */
55
+ export function parseProcConfig(config) {
56
+ if (config === undefined || config === null)
57
+ return [];
58
+ if (typeof config !== "object" || Array.isArray(config)) {
59
+ throw new Error("tool_config.proc must be a mapping (for example `env_reveal: [NODE_ENV]`)");
60
+ }
61
+ const block = config;
62
+ const snake = block["env_reveal"];
63
+ const camel = block["envReveal"];
64
+ if (snake !== undefined && camel !== undefined) {
65
+ throw new Error("tool_config.proc sets both env_reveal and envReveal; keep one");
66
+ }
67
+ const list = snake ?? camel;
68
+ if (list === undefined)
69
+ return [];
70
+ if (!Array.isArray(list) || list.some((n) => typeof n !== "string")) {
71
+ throw new Error(`${ENV_REVEAL_KEY} must be a list of environment variable names`);
72
+ }
73
+ const names = list;
74
+ for (const name of names) {
75
+ if (!isEnvName(name)) {
76
+ throw new Error(`${ENV_REVEAL_KEY} lists ${JSON.stringify(String(name).slice(0, 64))}, which is not an environment variable name`);
77
+ }
78
+ const shape = credentialShapeOf(name);
79
+ if (shape !== undefined) {
80
+ throw new Error(`${ENV_REVEAL_KEY} lists ${name}, whose name looks like a credential (${shape}); EnvInspect never shows such a value, so remove it`);
81
+ }
82
+ }
83
+ return Object.freeze([...new Set(names)].sort());
84
+ }
85
+ /**
86
+ * The boot registrar: `tool_config.proc`. A block under the own key of a
87
+ * tool that reads it — `tool_config.envInspect` or `tool_config.waitForPort`
88
+ * — reaches it too, when that tool is listed (the builtin table's boot rule;
89
+ * no other tool-proc tool names this registrar). Both keys are parsed before either is stored,
90
+ * so a block with one bad key changes nothing.
91
+ */
92
+ export function registerProcConfig(config) {
93
+ const reveal = parseProcConfig(config);
94
+ const hosts = parsePortHosts(config);
95
+ envReveal = reveal;
96
+ portHosts = hosts;
97
+ }
98
+ /**
99
+ * The extra hosts ONE WaitForPort call may probe: the serving candidate's
100
+ * block when it declares one, else the boot registration. A candidate block
101
+ * that does not parse allows nothing beyond loopback.
102
+ */
103
+ export function portHostsFor(override) {
104
+ if (override === undefined)
105
+ return portHosts;
106
+ try {
107
+ return parsePortHosts(override);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * The reveal list ONE call runs under: the serving candidate's block when it
115
+ * declares one (replacing the boot registration, as every package's
116
+ * per-call override does), else the boot registration. A candidate block
117
+ * that does not parse reveals nothing rather than falling back.
118
+ */
119
+ export function revealAllowFor(override) {
120
+ if (override === undefined)
121
+ return envReveal;
122
+ try {
123
+ return parseProcConfig(override);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ /** Test-only: back to the default, which reveals nothing and probes loopback only. */
130
+ export function _resetProcConfig() {
131
+ envReveal = [];
132
+ portHosts = [];
133
+ }
package/dist/lib/env.d.ts CHANGED
@@ -27,21 +27,34 @@ export declare function buildSpawnEnv(parent: Readonly<Record<string, string | u
27
27
  readonly forward?: readonly string[];
28
28
  readonly set?: Readonly<Record<string, string>>;
29
29
  }): SpawnEnvResult;
30
- /** One variable as EnvInspect reports it. `value` is present only for names
31
- * the caller explicitly asked to reveal. */
30
+ /** Why a value named in `reveal` was not shown. */
31
+ export type WithheldReason = "credential-shaped" | "not-allowed";
32
+ /** One variable as EnvInspect reports it. `value` is present only for a
33
+ * name the call asked to reveal AND the reveal policy allowed; `withheld`
34
+ * says why a value asked for was not shown. */
32
35
  export type EnvView = {
33
36
  readonly name: string;
34
37
  readonly present: boolean;
35
38
  readonly chars: number;
36
39
  readonly value?: string;
40
+ readonly withheld?: WithheldReason;
37
41
  };
42
+ /** Whether `name`'s value may be shown: the operator's list, never a credential. */
43
+ export type RevealPolicy = (name: string) => {
44
+ readonly ok: true;
45
+ } | {
46
+ readonly ok: false;
47
+ readonly code: string;
48
+ };
49
+ /** The default policy: nothing is ever shown. */
50
+ export declare const REVEAL_NOTHING: RevealPolicy;
38
51
  /**
39
52
  * Inspect named variables and nothing else. There is deliberately no way to
40
53
  * enumerate the environment: a caller must know the name to learn anything,
41
- * and learns only whether it is set and how long it is unless it names the
42
- * variable in `reveal`.
54
+ * and learns only whether it is set and how long it is — a value only for a
55
+ * name in `reveal` that `mayReveal` allows (C052).
43
56
  */
44
- export declare function inspectEnv(parent: Readonly<Record<string, string | undefined>>, names: readonly string[], reveal?: readonly string[]): {
57
+ export declare function inspectEnv(parent: Readonly<Record<string, string | undefined>>, names: readonly string[], reveal?: readonly string[], mayReveal?: RevealPolicy): {
45
58
  readonly views: EnvView[];
46
59
  readonly invalid: readonly string[];
47
60
  };
package/dist/lib/env.js CHANGED
@@ -55,13 +55,15 @@ export function buildSpawnEnv(parent, options = {}) {
55
55
  env[name] = out.get(name);
56
56
  return { env, missing, invalid };
57
57
  }
58
+ /** The default policy: nothing is ever shown. */
59
+ export const REVEAL_NOTHING = () => ({ ok: false, code: "not-allowed" });
58
60
  /**
59
61
  * Inspect named variables and nothing else. There is deliberately no way to
60
62
  * enumerate the environment: a caller must know the name to learn anything,
61
- * and learns only whether it is set and how long it is unless it names the
62
- * variable in `reveal`.
63
+ * and learns only whether it is set and how long it is — a value only for a
64
+ * name in `reveal` that `mayReveal` allows (C052).
63
65
  */
64
- export function inspectEnv(parent, names, reveal = []) {
66
+ export function inspectEnv(parent, names, reveal = [], mayReveal = REVEAL_NOTHING) {
65
67
  const revealSet = new Set(reveal);
66
68
  const invalid = [];
67
69
  const views = [];
@@ -80,7 +82,19 @@ export function inspectEnv(parent, names, reveal = []) {
80
82
  present: value !== undefined,
81
83
  chars: value === undefined ? 0 : value.length,
82
84
  };
83
- views.push(value !== undefined && revealSet.has(name) ? { ...base, value } : base);
85
+ if (!revealSet.has(name)) {
86
+ views.push(base);
87
+ continue;
88
+ }
89
+ const decision = mayReveal(name);
90
+ if (!decision.ok) {
91
+ views.push({
92
+ ...base,
93
+ withheld: decision.code === "credential-shaped" ? "credential-shaped" : "not-allowed",
94
+ });
95
+ continue;
96
+ }
97
+ views.push(value !== undefined ? { ...base, value } : base);
84
98
  }
85
99
  // Sorted by name so two calls with the same names in a different order
86
100
  // return the same bytes.
@@ -1,8 +1,3 @@
1
- /**
2
- * Pure formatting helpers for the process tools. Nothing here spawns,
3
- * reads the filesystem or looks at a clock, so the behaviour that these
4
- * back can be unit-tested without a world.
5
- */
6
1
  export type CappedText = {
7
2
  readonly text: string;
8
3
  readonly truncated: boolean;
@@ -26,8 +21,18 @@ export declare function formatArgv(argv: readonly string[]): string;
26
21
  /**
27
22
  * Compile a caller-supplied regular expression, refusing the shapes behind
28
23
  * catastrophic backtracking. The pattern reaches us from a model, and a
29
- * waiting tool applies it repeatedly to a growing buffer, so an exponential
30
- * pattern would pin a core for the whole deadline.
24
+ * waiting tool applies it repeatedly to a growing buffer.
25
+ *
26
+ * The screen is @crewhaus/tool-safety's `compileUserRegex`, the one every
27
+ * tool shares (C079). 0.7.0 kept a scanner here that counted only `*` and
28
+ * `+` inside a group, so `(\w{1,})*$`, `(\w{1,64})*$` and `(\w\w?)*$`
29
+ * passed it and JavaScriptCore then gave up on them — a false "no match"
30
+ * after pinning the event loop. The shared screen models `{n,m}` and `?`
31
+ * too, accepts a repetition only when each pass ends in exactly one place,
32
+ * and is fuzzed; it also admits safe alternations the old scanner refused
33
+ * wholesale, such as `(a|b)*`. WaitForOutput still runs the match in the
34
+ * regex worker under a deadline: the screen is the second layer, not the
35
+ * first.
31
36
  */
32
37
  export declare function compileSafePattern(source: string, flags: string): {
33
38
  readonly ok: true;