@crewhaus/tool-proc 0.7.0 → 0.8.0

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/index.js CHANGED
@@ -26,14 +26,21 @@ import { accessSync, constants as fsConstants, statSync } from "node:fs";
26
26
  import { connect } from "node:net";
27
27
  import * as path from "node:path";
28
28
  import { buildTool } from "@crewhaus/tool-builder";
29
+ import { checkEnvReveal } from "@crewhaus/tool-safety/env";
30
+ import { describeRegexOutcome, openRegexSession } from "@crewhaus/tool-safety/regex";
29
31
  import { z } from "zod";
32
+ import { portTarget, resolveListedHost } from "./lib/addr";
30
33
  import { backoffDelayMs, totalBackoffMs } from "./lib/backoff";
34
+ import { ENV_REVEAL_KEY, PORT_HOSTS_KEY, portHostsFor, revealAllowFor } from "./lib/config";
31
35
  import { FALLBACK_PATH, buildSpawnEnv, inspectEnv } from "./lib/env";
32
36
  import { capText, compileSafePattern, formatArgv } from "./lib/format";
33
- import { __resetRegistryForTest, getProc, listProcs, markKilled, reapProc, startBackground, } from "./registry";
37
+ import { hostPlatform, searchPath } from "./lib/which";
38
+ import { __resetRegistryForTest, __stopAllForTest, getProc, listProcs, markKilled, reapProc, startBackground, watchFinished, } from "./registry";
34
39
  import { recheckContainment, resolveSafe, resolveSafeDir } from "./safe-path";
35
40
  import { runOnce, sleep } from "./spawn";
36
- export { __resetRegistryForTest };
41
+ export { __resetRegistryForTest, __stopAllForTest };
42
+ export { _resetProcConfig, registerProcConfig } from "./lib/config";
43
+ export { _setDnsLookup } from "./lib/addr";
37
44
  /** Compact JSON — the reader is a model, not a person. */
38
45
  const json = (value) => JSON.stringify(value);
39
46
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -43,6 +50,8 @@ const MAX_WAIT_MS = 600_000;
43
50
  const DEFAULT_MAX_OUTPUT = 100_000;
44
51
  const MAX_MAX_OUTPUT = 1_000_000;
45
52
  const DEFAULT_POLL_MS = 100;
53
+ /** The longest one WaitForOutput match may run in the regex worker. */
54
+ const MATCH_BUDGET_MS = 2_000;
46
55
  // ---------------------------------------------------------------------------
47
56
  // shared schema pieces
48
57
  // ---------------------------------------------------------------------------
@@ -129,6 +138,9 @@ function outcomeJson(outcome) {
129
138
  stderr: outcome.stderr,
130
139
  ...(outcome.stdoutTruncated ? { stdoutTruncated: true } : {}),
131
140
  ...(outcome.stderrTruncated ? { stderrTruncated: true } : {}),
141
+ // The child exited, but a process it started held its output open past
142
+ // the drain grace: what came back is what arrived, perhaps not all of it.
143
+ ...(outcome.outputIncomplete === true ? { outputIncomplete: true } : {}),
132
144
  };
133
145
  }
134
146
  // ---------------------------------------------------------------------------
@@ -136,7 +148,8 @@ function outcomeJson(outcome) {
136
148
  // ---------------------------------------------------------------------------
137
149
  export const runCommand = buildTool({
138
150
  name: "RunCommand",
139
- description: "Run a program from an argv array — the program and each argument as separate strings, with no shell anywhere, so an argument containing a space, a quote or $(...) stays an argument. Use it whenever a harness needs a program's exit code and output without the injection surface of a shell command line. The child inherits no environment except the names you forward, and always has a timeout.",
151
+ operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
152
+ description: "Run a program from an argv array — the program and each argument as separate strings, with no shell anywhere, so an argument containing a space, a quote or $(...) stays an argument. Use it whenever a harness needs a program's exit code and output without the injection surface of a shell command line. The child inherits no environment except the names you forward, always has a timeout, and runs in a session of its own with no terminal: a program that prompts (sudo, ssh, gpg) fails instead of waiting for input.",
140
153
  inputSchema: z.object({
141
154
  argv: argvSchema,
142
155
  cwd: z.string().optional().describe("working directory, inside the workspace root"),
@@ -172,7 +185,10 @@ export const runCommand = buildTool({
172
185
  });
173
186
  export const runPipeline = buildTool({
174
187
  name: "RunPipeline",
175
- description: "Run several argv commands in order, stopping at the first non-zero exit, and return every step's result. Use it for a short ordered chain — install, then build, then test — without spending a model turn between the steps. Steps run in sequence and do not pipe into each other; each gets its own stdin and its own timeout.",
188
+ operativeArgs: [
189
+ { field: "steps.argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" },
190
+ ],
191
+ description: "Run several argv commands in order, stopping at the first non-zero exit, and return every step's result. Use it for a short ordered chain — install, then build, then test — without spending a model turn between the steps. Steps run in sequence and do not pipe into each other; each gets its own stdin and its own timeout, and runs with no terminal, so a step that prompts fails instead of waiting.",
176
192
  inputSchema: z.object({
177
193
  steps: z
178
194
  .array(z.object({
@@ -271,7 +287,8 @@ const backoffSchema = z
271
287
  .describe("the wait between attempts; declared by the caller, with no jitter, so a retry plan replays identically");
272
288
  export const retry = buildTool({
273
289
  name: "Retry",
274
- description: "Re-run an argv command until it succeeds or a bounded attempt count runs out, waiting a caller-declared backoff between attempts. Use it for a flaky step — a service still starting, a lock still held — instead of asking a model to decide when to try again. The backoff is fixed or exponential with an explicit base and carries no jitter, and every attempt is reported.",
290
+ operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
291
+ description: "Re-run an argv command until it succeeds or a bounded attempt count runs out, waiting a caller-declared backoff between attempts. Use it for a flaky step — a service still starting, a lock still held — instead of asking a model to decide when to try again. The backoff is fixed or exponential with an explicit base and carries no jitter, and every attempt is reported. Each attempt runs with no terminal, so one that prompts fails instead of waiting.",
275
292
  inputSchema: z.object({
276
293
  argv: argvSchema,
277
294
  maxAttempts: z.number().int().min(1).max(10),
@@ -390,6 +407,7 @@ function noSuchProc(toolName, id) {
390
407
  }
391
408
  export const processStart = buildTool({
392
409
  name: "ProcessStart",
410
+ operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
393
411
  description: "Start a program in the background from an argv array and return an id immediately, without waiting for it to finish. Use it for something that must outlive one tool call — a dev server, a watcher, a tail — then poll it with ProcessStatus and ProcessOutput and end it with ProcessStop. The process is killed if the harness exits, so a session never leaks children.",
394
412
  inputSchema: z.object({
395
413
  argv: argvSchema,
@@ -439,6 +457,7 @@ export const processStatus = buildTool({
439
457
  });
440
458
  export const processOutput = buildTool({
441
459
  name: "ProcessOutput",
460
+ operativeArgs: [],
442
461
  description: "Return what a background process has written since the last poll, and its current status. Use it to follow a long-running process incrementally instead of buffering everything until it exits. Each call advances a per-stream cursor, so output is returned exactly once — use ProcessStatus for a look that consumes nothing.",
443
462
  inputSchema: z.object({
444
463
  id: procIdSchema,
@@ -475,6 +494,7 @@ export const processOutput = buildTool({
475
494
  });
476
495
  export const processStop = buildTool({
477
496
  name: "ProcessStop",
497
+ operativeArgs: [],
478
498
  description: "Stop a background process: signal it, wait a bounded grace period, then SIGKILL it if it is still alive. Use it to end a dev server or watcher deterministically rather than leaving it running past the task. Pass reap to drop the entry entirely; otherwise it stays readable so its final output can still be collected.",
479
499
  inputSchema: z.object({
480
500
  id: procIdSchema,
@@ -618,7 +638,8 @@ function probePort(host, port, timeoutMs) {
618
638
  const HOSTNAME = /^[A-Za-z0-9._:-]+$/;
619
639
  export const waitForPort = buildTool({
620
640
  name: "WaitForPort",
621
- description: "Poll a TCP host and port until it is accepting connections, or until it stops, within a required deadline. Use it to wait for a server the harness just started to be ready, instead of guessing with a sleep. It reports whether the condition was met and how many probes it took, and never waits past the deadline.",
641
+ operativeArgs: [{ field: "host", kind: "recipient", default: "127.0.0.1" }],
642
+ description: "Poll a TCP host and port until it is accepting connections, or until it stops, within a required deadline. Use it to wait for a server the harness just started to be ready, instead of guessing with a sleep. It reports whether the condition was met and how many probes it took, and never waits past the deadline. It probes loopback (localhost, 127.0.0.1, ::1; 0.0.0.0 and :: are probed as loopback); any other host is refused unless the operator lists it in tool_config.proc.wait_for_port_hosts, and a listed name that does not resolve yet counts as not accepting connections.",
622
643
  inputSchema: z.object({
623
644
  port: z.number().int().min(1).max(65_535),
624
645
  host: z.string().max(255).optional().describe("default 127.0.0.1"),
@@ -637,16 +658,39 @@ export const waitForPort = buildTool({
637
658
  if (!HOSTNAME.test(host)) {
638
659
  return `[WaitForPort error] "${host}" is not a hostname or address.`;
639
660
  }
661
+ // Loopback, or a host the operator listed — decided before any DNS or
662
+ // socket, and dialled by the address checked (C144).
663
+ const allowed = portHostsFor(ctx?.toolConfig);
664
+ const target = portTarget(host, allowed, PORT_HOSTS_KEY);
665
+ if (!target.ok)
666
+ return target.message;
640
667
  const want = input.state ?? "open";
641
668
  const interval = input.intervalMs ?? DEFAULT_POLL_MS;
642
669
  const startedAt = Date.now();
643
670
  const deadline = startedAt + input.timeoutMs;
644
671
  let attempts = 0;
645
672
  let open = false;
673
+ // A listed name is looked up at each probe until it answers, then that
674
+ // address is dialled for the rest of the call. Until then the host
675
+ // accepts no connections: "not open yet", or already "closed".
676
+ let dial = "dial" in target ? target.dial : undefined;
677
+ const unresolvedNote = () => dial === undefined
678
+ ? { note: `"${host}" did not resolve; a name with no address accepts no connections` }
679
+ : {};
646
680
  while (Date.now() < deadline) {
647
681
  const remaining = deadline - Date.now();
648
682
  attempts++;
649
- open = await probePort(host, input.port, Math.max(1, Math.min(1_000, remaining)));
683
+ if (dial === undefined && "resolve" in target) {
684
+ const resolved = await resolveListedHost(target.resolve, allowed, PORT_HOSTS_KEY);
685
+ if (!resolved.ok)
686
+ return resolved.message;
687
+ if ("dial" in resolved)
688
+ dial = resolved.dial;
689
+ }
690
+ open =
691
+ dial === undefined
692
+ ? false
693
+ : await probePort(dial, input.port, Math.max(1, Math.min(1_000, remaining)));
650
694
  if ((want === "open") === open) {
651
695
  return json({
652
696
  satisfied: true,
@@ -655,6 +699,7 @@ export const waitForPort = buildTool({
655
699
  port: input.port,
656
700
  attempts,
657
701
  waitedMs: Date.now() - startedAt,
702
+ ...unresolvedNote(),
658
703
  });
659
704
  }
660
705
  if (ctx?.signal?.aborted === true)
@@ -673,6 +718,7 @@ export const waitForPort = buildTool({
673
718
  attempts,
674
719
  waitedMs: Date.now() - startedAt,
675
720
  reason: ctx?.signal?.aborted === true ? "aborted" : "deadline",
721
+ ...unresolvedNote(),
676
722
  });
677
723
  },
678
724
  });
@@ -788,7 +834,7 @@ export const waitForFile = buildTool({
788
834
  });
789
835
  export const waitForOutput = buildTool({
790
836
  name: "WaitForOutput",
791
- description: "Watch a background process's output until a pattern matches, a failure pattern matches first, or a required deadline passes. Use it to wait for the line that means ready — 'Listening on', 'compiled successfully' — and to give up early when the line that means broken shows up instead. It reads without consuming, so ProcessOutput still returns everything afterwards.",
837
+ description: "Watch a background process's output until a pattern matches, a failure pattern matches first, or a required deadline passes. Use it to wait for the line that means ready — 'Listening on', 'compiled successfully' — and to give up early when the line that means broken shows up instead. It reads without consuming, so ProcessOutput still returns everything afterwards. A pattern that could not be evaluated ends the wait as undetermined (matched: null), never as a miss.",
792
838
  inputSchema: z.object({
793
839
  id: procIdSchema,
794
840
  pattern: z.string().min(1).describe("a JavaScript regular expression source"),
@@ -810,83 +856,142 @@ export const waitForOutput = buildTool({
810
856
  const bg = getProc(input.id);
811
857
  if (bg === undefined)
812
858
  return noSuchProc("WaitForOutput", input.id);
813
- const success = compileSafePattern(input.pattern, input.flags ?? "");
859
+ const flags = input.flags ?? "";
860
+ // Screened up front so a refused pattern never runs (C079)…
861
+ const success = compileSafePattern(input.pattern, flags);
814
862
  if (!success.ok)
815
863
  return `[WaitForOutput error] ${success.message}`;
816
- let failure;
817
864
  if (input.failurePattern !== undefined) {
818
- const compiled = compileSafePattern(input.failurePattern, input.flags ?? "");
865
+ const compiled = compileSafePattern(input.failurePattern, flags);
819
866
  if (!compiled.ok)
820
867
  return `[WaitForOutput error] failurePattern — ${compiled.message}`;
821
- failure = compiled.regex;
822
868
  }
823
869
  const which = input.stream ?? "both";
824
870
  const interval = input.intervalMs ?? DEFAULT_POLL_MS;
825
871
  const startedAt = Date.now();
826
872
  const deadline = startedAt + input.timeoutMs;
873
+ // Whether a look is the last one is decided BEFORE it is taken. The
874
+ // exit, and the pipes' last bytes, can land while a match is running in
875
+ // the worker, so a process seen finished only afterwards gets one more
876
+ // look. What "finished" means for an exited process (its watched pipe has
877
+ // ended too) and a stopped one (its exit has been seen) is
878
+ // watchFinished's.
879
+ const finished = () => watchFinished(bg, which);
827
880
  const haystack = () => which === "stdout"
828
881
  ? bg.stdout
829
882
  : which === "stderr"
830
883
  ? bg.stderr
831
884
  : `${bg.stdout}\n${bg.stderr}`;
832
- for (;;) {
833
- const text = haystack();
834
- const failed = failure?.exec(text) ?? null;
835
- if (failed !== null) {
836
- return json({
837
- matched: false,
838
- matchedFailure: true,
839
- id: bg.id,
840
- match: failed[0],
841
- status: bg.status,
842
- exitCode: bg.exitCode,
843
- waitedMs: Date.now() - startedAt,
844
- });
845
- }
846
- const hit = success.regex.exec(text);
847
- if (hit !== null) {
848
- return json({
849
- matched: true,
850
- id: bg.id,
851
- match: hit[0],
852
- status: bg.status,
853
- exitCode: bg.exitCode,
854
- waitedMs: Date.now() - startedAt,
855
- });
856
- }
857
- // A process that has exited will never produce the line; one last look
858
- // has already happened above, so stop instead of burning the deadline.
859
- if (bg.status !== "running") {
860
- return json({
861
- matched: false,
862
- id: bg.id,
863
- reason: "process finished without matching",
864
- status: bg.status,
865
- exitCode: bg.exitCode,
866
- waitedMs: Date.now() - startedAt,
867
- });
868
- }
869
- if (ctx?.signal?.aborted === true) {
870
- return json({
871
- matched: false,
872
- id: bg.id,
873
- reason: "aborted",
874
- status: bg.status,
875
- waitedMs: Date.now() - startedAt,
876
- });
877
- }
878
- const left = deadline - Date.now();
879
- if (left <= 0) {
880
- return json({
881
- matched: false,
882
- id: bg.id,
883
- reason: "deadline",
884
- status: bg.status,
885
- exitCode: bg.exitCode,
886
- waitedMs: Date.now() - startedAt,
887
- });
885
+ // …and every match runs in @crewhaus/tool-safety's regex worker, under
886
+ // a deadline: a synchronous exec over this buffer could not be
887
+ // interrupted, and JavaScriptCore abandons a runaway one as "no match".
888
+ // An answer the worker could not give ends the wait as undetermined —
889
+ // never as "deadline", and never as a match that did not happen.
890
+ const session = openRegexSession();
891
+ const firstMatch = async (pattern, text) => {
892
+ const outcome = await session.run({
893
+ op: "matchAll",
894
+ pattern,
895
+ flags,
896
+ input: text,
897
+ maxMatches: 1,
898
+ deadlineMs: Math.max(100, Math.min(MATCH_BUDGET_MS, deadline - Date.now())),
899
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
900
+ ...(ctx?.runContext !== undefined ? { runawayKey: ctx.runContext.sessionId } : {}),
901
+ });
902
+ if (outcome.status !== "ok")
903
+ return { ok: false, why: describeRegexOutcome(outcome) };
904
+ return { ok: true, match: outcome.result.matches[0]?.match };
905
+ };
906
+ const undetermined = (which, why) =>
907
+ // A run the caller's own abort ended is the abort, as before.
908
+ ctx?.signal?.aborted === true
909
+ ? json({
910
+ matched: false,
911
+ id: bg.id,
912
+ reason: "aborted",
913
+ status: bg.status,
914
+ waitedMs: Date.now() - startedAt,
915
+ })
916
+ : json({
917
+ matched: null,
918
+ id: bg.id,
919
+ reason: "undetermined",
920
+ detail: `${which} could not be evaluated against the output: ${why}`,
921
+ status: bg.status,
922
+ exitCode: bg.exitCode,
923
+ waitedMs: Date.now() - startedAt,
924
+ });
925
+ try {
926
+ for (;;) {
927
+ const last = finished();
928
+ const text = haystack();
929
+ if (input.failurePattern !== undefined) {
930
+ const failed = await firstMatch(input.failurePattern, text);
931
+ if (!failed.ok)
932
+ return undetermined("failurePattern", failed.why);
933
+ if (failed.match !== undefined) {
934
+ return json({
935
+ matched: false,
936
+ matchedFailure: true,
937
+ id: bg.id,
938
+ match: failed.match,
939
+ status: bg.status,
940
+ exitCode: bg.exitCode,
941
+ waitedMs: Date.now() - startedAt,
942
+ });
943
+ }
944
+ }
945
+ const hit = await firstMatch(input.pattern, text);
946
+ if (!hit.ok)
947
+ return undetermined("pattern", hit.why);
948
+ if (hit.match !== undefined) {
949
+ return json({
950
+ matched: true,
951
+ id: bg.id,
952
+ match: hit.match,
953
+ status: bg.status,
954
+ exitCode: bg.exitCode,
955
+ waitedMs: Date.now() - startedAt,
956
+ });
957
+ }
958
+ // A process that had finished before this look will never produce the
959
+ // line, so stop instead of burning the deadline.
960
+ if (last) {
961
+ return json({
962
+ matched: false,
963
+ id: bg.id,
964
+ reason: "process finished without matching",
965
+ status: bg.status,
966
+ exitCode: bg.exitCode,
967
+ waitedMs: Date.now() - startedAt,
968
+ });
969
+ }
970
+ if (ctx?.signal?.aborted === true) {
971
+ return json({
972
+ matched: false,
973
+ id: bg.id,
974
+ reason: "aborted",
975
+ status: bg.status,
976
+ waitedMs: Date.now() - startedAt,
977
+ });
978
+ }
979
+ const left = deadline - Date.now();
980
+ if (left <= 0) {
981
+ return json({
982
+ matched: false,
983
+ id: bg.id,
984
+ reason: "deadline",
985
+ status: bg.status,
986
+ exitCode: bg.exitCode,
987
+ waitedMs: Date.now() - startedAt,
988
+ });
989
+ }
990
+ await sleep(Math.min(interval, left), ctx?.signal);
888
991
  }
889
- await sleep(Math.min(interval, left), ctx?.signal);
992
+ }
993
+ finally {
994
+ session.close();
890
995
  }
891
996
  },
892
997
  });
@@ -895,7 +1000,7 @@ export const waitForOutput = buildTool({
895
1000
  // ---------------------------------------------------------------------------
896
1001
  export const commandExists = buildTool({
897
1002
  name: "CommandExists",
898
- description: "Report whether a program is on PATH and where it resolves, without running it. Use it to check a prerequisite before building a plan around it, so a missing binary is a clear answer rather than a failed command. It searches the same PATH RunCommand would use, in order, and returns the first executable match.",
1003
+ description: "Report whether a program is on PATH and where it resolves, without running it. Use it to check a prerequisite before building a plan around it, so a missing binary is a clear answer rather than a failed command. It searches the same PATH RunCommand would use, in order, and returns the first executable match; on Windows it tries PATHEXT's extensions (git finds git.exe) as the shell does.",
899
1004
  inputSchema: z.object({
900
1005
  name: z.string().min(1).max(255).describe("a bare program name such as 'git' — not a path"),
901
1006
  }),
@@ -905,26 +1010,26 @@ export const commandExists = buildTool({
905
1010
  if (input.name.includes("/") || input.name.includes("\\") || input.name.includes("\0")) {
906
1011
  return `[CommandExists error] "${input.name}" is a path, not a program name — pass a bare name such as "git".`;
907
1012
  }
908
- const raw = process.env["PATH"] ?? FALLBACK_PATH;
909
- const dirs = raw.split(path.delimiter).filter((d) => d !== "");
910
- for (const dir of dirs) {
911
- const candidate = path.join(dir, input.name);
912
- try {
913
- if (!statSync(candidate).isFile())
914
- continue;
915
- accessSync(candidate, fsConstants.X_OK);
916
- return json({ name: input.name, found: true, path: candidate, searchedDirs: dirs.length });
917
- }
918
- catch {
919
- // Not here, or not executable — keep looking.
920
- }
921
- }
922
- return json({ name: input.name, found: false, path: null, searchedDirs: dirs.length });
1013
+ const platform = hostPlatform();
1014
+ // The PATH RunCommand hands its child (buildSpawnEnv). FALLBACK_PATH is
1015
+ // a POSIX list; a Windows host with no PATH has nothing to search.
1016
+ const raw = process.env["PATH"] ?? (platform === "win32" ? "" : FALLBACK_PATH);
1017
+ const found = searchPath(input.name, {
1018
+ pathValue: raw,
1019
+ platform,
1020
+ pathext: process.env["PATHEXT"],
1021
+ });
1022
+ return json({
1023
+ name: input.name,
1024
+ found: found.path !== null,
1025
+ path: found.path,
1026
+ searchedDirs: found.searchedDirs,
1027
+ });
923
1028
  },
924
1029
  });
925
1030
  export const envInspect = buildTool({
926
1031
  name: "EnvInspect",
927
- description: "Report whether named environment variables are set, and how long their values are, revealing a value only when the caller names it. Use it to check that a credential or configuration variable is present before running something that needs it, without pulling the secret into context. There is no way to list the environment: a name you do not ask for is a name you learn nothing about.",
1032
+ description: "Report whether named environment variables are set, and how long their values are. Use it to check that a credential or configuration variable is present before running something that needs it, without pulling the secret into context. A value is shown only for a variable named in `reveal` that the operator listed in tool_config.proc.env_reveal, and never for one whose name looks like a credential (a key, token, secret, password or credential URL), listed or not; any other name in `reveal` comes back with `withheld` and the reason. There is no way to list the environment: a name you do not ask for is a name you learn nothing about.",
928
1033
  inputSchema: z.object({
929
1034
  names: z
930
1035
  .array(z.string().min(1).max(255))
@@ -935,22 +1040,29 @@ export const envInspect = buildTool({
935
1040
  .array(z.string().min(1).max(255))
936
1041
  .max(16)
937
1042
  .optional()
938
- .describe("variables whose actual value should be returned; must also appear in names"),
1043
+ .describe("variables whose value should be returned; must also appear in names, and each must be listed in tool_config.proc.env_reveal and not look like a credential"),
939
1044
  }),
940
1045
  readOnly: true,
941
1046
  concurrencySafe: true,
942
- execute: async (input) => {
1047
+ execute: async (input, ctx) => {
943
1048
  const named = new Set(input.names);
944
1049
  const reveal = input.reveal ?? [];
945
1050
  const notNamed = reveal.filter((r) => !named.has(r));
946
1051
  if (notNamed.length > 0) {
947
1052
  return `[EnvInspect error] reveal lists ${notNamed.join(", ")}, which names does not — add them to names to inspect them.`;
948
1053
  }
949
- const { views, invalid } = inspectEnv(process.env, input.names, reveal);
1054
+ // The list comes from the operator (tool_config), never from the call.
1055
+ const allowed = revealAllowFor(ctx?.toolConfig);
1056
+ const { views, invalid } = inspectEnv(process.env, input.names, reveal, (name) => checkEnvReveal(name, { allowed, configKey: ENV_REVEAL_KEY }));
1057
+ const withheld = views.filter((v) => v.withheld !== undefined);
950
1058
  return json({
951
1059
  variables: views,
952
1060
  ...(invalid.length > 0 ? { notEnvironmentNames: invalid } : {}),
953
- note: "values are withheld unless named in reveal",
1061
+ note: withheld.length === 0
1062
+ ? "values are shown only for names in reveal that tool_config.proc.env_reveal lists"
1063
+ : `values are shown only for names in reveal that ${ENV_REVEAL_KEY} lists, and never for a name that looks like a credential; ${withheld
1064
+ .map((v) => `${v.name}: ${v.withheld}`)
1065
+ .join(", ")}`,
954
1066
  });
955
1067
  },
956
1068
  });
@@ -0,0 +1,129 @@
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
+ /**
19
+ * Canonicalise an IPv4 literal.
20
+ *
21
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
22
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
23
+ * only understands dotted-decimal waves every one of them through. Returns
24
+ * `null` when the string is not an IPv4 literal at all.
25
+ */
26
+ export declare function normalizeIpv4(raw: string): string | null;
27
+ /**
28
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
29
+ * is not one.
30
+ *
31
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
32
+ * has many spellings and the one a check was written against is rarely the one
33
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
34
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
35
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
36
+ * first means the prefix tests below are arithmetic, and spelling stops
37
+ * mattering.
38
+ */
39
+ export declare function parseIpv6(raw: string): ReadonlyArray<number> | null;
40
+ /** Ranges that are never a public RPC endpoint. */
41
+ export declare function isPrivateIp(address: string): boolean;
42
+ /** True for 127.0.0.0/8, ::1, and a v6 spelling that carries a 127/8 address. */
43
+ export declare function isLoopbackIp(address: string): boolean;
44
+ /**
45
+ * True for link-local addresses, where cloud metadata services live:
46
+ * 169.254.0.0/16, fe80::/10, AWS's fd00:ec2::254, and a v6 spelling that
47
+ * carries a 169.254/16 address.
48
+ */
49
+ export declare function isLinkLocalIp(address: string): boolean;
50
+ /** The canonical spelling of an IP literal (dotted quad, or v6 as written), or null. */
51
+ export declare function canonicalIp(address: string): string | null;
52
+ export type DnsLookup = (host: string) => Promise<ReadonlyArray<string>>;
53
+ /** Test seam: replace the resolver; `undefined` restores the system one. */
54
+ export declare function _setDnsLookup(fn: DnsLookup | undefined): void;
55
+ /**
56
+ * True for the unspecified address in any spelling (0.0.0.0, ::, an
57
+ * IPv4-mapped 0.0.0.0). A server that binds it prints it ("running on
58
+ * http://0.0.0.0:8000"), and dialling it reaches only this machine on Linux
59
+ * and macOS, so WaitForPort treats it as loopback and dials loopback itself,
60
+ * which also works where connecting to 0.0.0.0 does not (Windows).
61
+ */
62
+ export declare function unspecifiedDial(address: string): string | null;
63
+ /**
64
+ * An IPv6 literal in the form WaitForPort dials it, or `null` when it is not
65
+ * one or may not be dialled.
66
+ *
67
+ * The form is the URL parser's (`0:0:0:0:0:ffff:127.0.0.1` is
68
+ * `::ffff:7f00:1`), which is how a permission rule reads a host too, so the
69
+ * probe goes to the address classified here and decided on there, and no
70
+ * resolver reads the string again. A spelling the URL parser does not read
71
+ * as the address {@link parseIpv6} does has no one reading, and is not
72
+ * dialled: an IPv4 part with a leading zero is octal to parseIpv6, decimal
73
+ * to macOS's resolver and invalid to the URL parser, so `::ffff:0177.0.0.1`
74
+ * was classified as loopback and reached 177.0.0.1.
75
+ */
76
+ export declare function ipv6Dial(address: string): string | null;
77
+ export type PortTarget = {
78
+ readonly ok: true;
79
+ readonly dial: string;
80
+ }
81
+ /** A listed name, resolved at probe time by {@link resolveListedHost}. */
82
+ | {
83
+ readonly ok: true;
84
+ readonly resolve: string;
85
+ } | {
86
+ readonly ok: false;
87
+ readonly message: string;
88
+ };
89
+ /**
90
+ * Decide what one WaitForPort call dials for `host`, or why it may not.
91
+ * Nothing here touches DNS.
92
+ *
93
+ * - `localhost`, and any IP literal that is loopback in any spelling, is
94
+ * allowed with no configuration (a literal is dialled in its canonical
95
+ * form, so `0x7f.1` dials 127.0.0.1, and an IPv6 one in the URL parser's,
96
+ * see {@link ipv6Dial}). So is the unspecified address (0.0.0.0, ::),
97
+ * dialled as 127.0.0.1 / ::1.
98
+ * - An IPv6 literal with no one reading (`::ffff:0177.0.0.1`) is refused,
99
+ * listed or not.
100
+ * - Any other host must be listed in `allowed` (the operator's
101
+ * `tool_config.proc.wait_for_port_hosts`), and is refused BEFORE any DNS
102
+ * query, so an unlisted name is not a way to send one either.
103
+ * - A listed literal is dialled in that canonical form; a listed NAME is
104
+ * resolved at probe time (`resolveListedHost`).
105
+ */
106
+ export declare function portTarget(host: string, allowed: ReadonlyArray<string>, configKey: string): PortTarget;
107
+ export type ListedResolution = {
108
+ readonly ok: true;
109
+ readonly dial: string;
110
+ }
111
+ /** No address yet: a name that does not resolve accepts no connections. */
112
+ | {
113
+ readonly ok: true;
114
+ readonly unresolved: true;
115
+ } | {
116
+ readonly ok: false;
117
+ readonly message: string;
118
+ };
119
+ /**
120
+ * Resolve a listed name for one probe. An empty answer, or a failed lookup,
121
+ * is "not accepting connections" for that probe, not a refusal: a compose
122
+ * service's name exists only once its container does, and a torn-down one
123
+ * stops resolving — which is exactly "closed". Every answer is checked: a
124
+ * name that resolves to a link-local address (the cloud metadata service) is
125
+ * refused unless that address itself is listed. The caller dials the address
126
+ * returned, and keeps dialling it for the rest of the call, so a resolver
127
+ * that answers differently later cannot move the probe.
128
+ */
129
+ export declare function resolveListedHost(host: string, allowed: ReadonlyArray<string>, configKey: string): Promise<ListedResolution>;