import { type SandboxMode, type EgressAttempt } from "./sandbox.js"; export type { EgressAttempt }; export interface RunScriptOptions { /** * Text piped to the program's stdin. Default: nothing. A program that ignores * stdin simply ignores it. (`runHook` uses this to deliver the JSON event.) */ readonly stdin?: string; /** Working directory for the process. Default: a value won't be set. */ readonly cwd?: string; /** Extra env vars (merged over process.env). `{cwd}` in values is left as-is. */ readonly env?: Record; /** Per-run timeout ms. Default 10000. */ readonly timeoutMs?: number; /** * Provenance of the hook command. `true` (default) means YOU authored it — the * usual case at this tier, a command written inline in the test — so it runs * directly. `false` marks it foreign (a vendored third-party hook script), * which makes confinement the DEFAULT: with no explicit `sandbox`, an untrusted * hook behaves as `sandbox: "auto"` — confined under bubblewrap, or refused if * none is available — so foreign code is never run unconfined by accident. This * mirrors the harness tier, where trust follows `plugin`/`pluginDir` * provenance (`specTrusted` in `src/sandbox.ts`); the unit tier takes a raw * command string with no provenance signal, so you declare it here. * * Why this is opt-in (untrusted) and not always-on: the sandbox isn't always * available (Linux + working userns only — forcing it would *refuse* a hook you * wrote on macOS/hardened CI), it's deliberately hostile (no egress, * `--clearenv`, empty HOME, read-only fs — a false failure for trusted code that * needs the network or an env var), trust follows provenance (you already * vouched for inline code), and direct exec is ms vs. the confined path's * setup+spawn. See `docs/sandboxing.md` ("Why confinement is opt-in"). Use * `sandbox: "strict"` to force confinement even on trusted code. */ readonly trusted?: boolean; /** * Confine the hook under bubblewrap (Linux). When unset, the mode follows * {@link RunHookOptions.trusted}: a trusted hook runs directly (`false`), an * untrusted one is confined-or-refused (`"auto"`). Set it explicitly to * override: `"auto"`/`"strict"` force confinement (a no-egress namespace with a * cleared environment — your `opts.env` is added back — or a **refusal** if no * bwrap is available), and `false` is the opt-out that runs even untrusted code * unconfined. macOS/Windows have no bwrap, so `"auto"`/`"strict"` throw there — * see `src/sandbox.ts`. */ readonly sandbox?: SandboxMode; /** * Record the hook's network egress. Implies confinement (the recorder lives in * the sandbox netns, so this forces a sandboxed run and refuses if no sandbox is * available). A recording proxy on loopback captures every `host:port` a * proxy-honoring tool (npm/pip/curl/fetch) tries to reach — surfaced as * {@link HookRunResult.egress} — while the netns still **blocks** it (nothing * actually leaves). Use it to test what a hook/skill phones home to, or which * registry an install would hit. Raw-socket egress is blocked but not recorded * (it never reaches the proxy) — the block is the boundary, the record is * best-effort observability over it. */ readonly recordEgress?: boolean; /** * Allowlisted egress: let the hook actually reach the network, but ONLY the * listed hosts, with the boundary at the **packet layer** (an `nft` allowlist * inside the sandbox netns, fed by `slirp4netns`) — so a raw socket to an * off-list host is dropped too, which a `recordEgress`/`HTTP_PROXY` allowlist * cannot guarantee. Use it to test a hook/skill whose setup needs a real * `npm install` from a registry you expect, and nothing else. Implies * confinement (it needs the netns), and **refuses** if bubblewrap + slirp4netns * + nft aren't available. The hosts are resolved to IPs at launch; results land * in {@link HookRunResult.egress} (the allowlisted hosts that were reached, with * `allowed: true`) and {@link HookRunResult.egressDropped} (how much off-list * traffic was blocked). See `docs/sandboxing.md`. */ readonly egress?: { readonly allow: readonly string[]; }; } /** What a program did: its exit, its output, and (when confined) its effects. */ export interface ScriptRunResult { /** Exit code. A signal-killed run reports 1. */ readonly exitCode: number; readonly stdout: string; /** * Everything the program wrote to fd 2. Advisory output usually lives HERE — * a runner that reads only stdout silently discards it. */ readonly stderr: string; /** * Network egress the run attempted. With {@link RunScriptOptions.recordEgress}: * every (blocked) attempt the proxy saw. With {@link RunScriptOptions.egress}: * the allowlisted hosts actually reached, with packet counts. Empty otherwise. */ readonly egress: readonly EgressAttempt[]; /** * Allowlisted-egress mode only: aggregate off-allowlist traffic the packet * wall dropped. Undefined when {@link RunScriptOptions.egress} was unset. */ readonly egressDropped?: { readonly packets: number; readonly bytes: number; }; /** * Files written to the work dir (relative paths). **`undefined` when writes * were never recorded** — a different fact from `[]` ("recorded, wrote * nothing"). Recording diffs the work dir before and after, which only a * CONFINED run does. `assertNoWrite` / `assertWroteOnly` refuse `undefined` * rather than pass having inspected nothing. */ readonly filesWritten?: readonly string[]; } /** The raw fields of a spawn that the result assembler needs. */ export interface ScriptSpawnResult { readonly status: number | null; readonly signal: string | null; readonly stdout: string; readonly stderr: string; /** Egress attempts captured by the in-sandbox recorder (recordEgress only). */ readonly egress?: readonly EgressAttempt[]; /** Off-allowlist traffic the packet-layer wall dropped (egress mode only). */ readonly egressDropped?: { readonly packets: number; readonly bytes: number; }; /** Files written to the work dir — present only on a CONFINED run. */ readonly filesWritten?: readonly string[]; } /** Spawn a program (command + stdin) — the injectable seam over the real spawn. */ export type ScriptSpawner = (command: string, stdin: string, opts: RunScriptOptions) => ScriptSpawnResult; /** The spawn seams `runScriptWith` needs, so its routing is testable. */ export interface RunScriptDeps { /** Whether bubblewrap confinement is available (Linux + bwrap). */ readonly available: boolean; /** Whether allowlisted egress is available (bwrap + slirp4netns + nft). */ readonly egressAvailable: boolean; /** Run the command directly (unconfined). */ readonly direct: ScriptSpawner; /** Run the command confined under bubblewrap. */ readonly sandboxed: ScriptSpawner; /** Run the command confined with an allowlisted-egress netns. */ readonly egress: ScriptSpawner; } /** * True when the exit code is the shell's own report that it never reached the * program — so nothing of the harness ran and no surface may be credited. * * 🔴 THE NUMBERS ARE MEASURED, NOT CITED. 126/127 are POSIX *conventions*; what * matters is what `spawnSync(cmd, { shell: true })` actually returns here. * Measured 2026-08-12, `/bin/sh` → dash (Debian), each case a real file: * * ``` * 126 | direct non-executable | ./noexec.sh | out="" | Permission denied * 127 | direct missing | ./missing.sh | out="" | not found * 127 | bare unknown command | nosuchcmd-xyz | out="" | not found * 127 | bad shebang (exists+x) | ./badshebang.sh | out="" | env: 'nosuchinterp' * 126 | is a directory | ./ | out="" | Permission denied * 3 | real hook, exit 3 | ./exit3.sh | out="ran" | * 0 | real hook, exit 0 | ./ok.sh | out="ran" | * ``` * * The exposure this closes: a harness may legitimately assert that a hook is * NOT executable (`assert.equal(runHook("./hooks/a.sh").exitCode, 126)`). That * test passes, records a check — and the unconditional probe used to credit * `hooks/a.sh` with an execution-tier record although only `/bin/sh` ran. The * file exists and IS a discovered surface, so `resolveProbe` resolves it happily. * * ⚠️ TWO SHAPES ARE DELIBERATELY MISSED, both toward SILENCE (a missed probe * costs one coverage line; a false grant costs the claim). Measured, same run: * * ``` * 2 | sh + missing | sh ./missing.sh | out="" | cannot open * 0 | sh + non-executable | sh ./noexec.sh | out="ran" | * 0 | bash + non-executable | bash ./noexec.sh | out="ran" | * 127 | ran, THEN failed to launch| ./ok.sh && ./missing.sh | out="ran" | * ``` * * - `sh ` exits **2** under dash, which is Claude Code's BLOCK code — * indistinguishable from a gate legitimately denying, so it cannot be encoded. * It is also mostly moot: a path that does not exist was never discovered as a * surface, and `resolveProbe` matches only discovered surfaces. * - `sh ` / `bash ` on a non-executable file exit **0 and print * "ran"** — the interpreter reads the file as an argument, so the exec bit is * irrelevant and the hook genuinely EXECUTED. Those must keep attributing, and * do. * - A compound whose last leaf fails to launch reports 126/127 for the whole * line even though an earlier leaf ran. We abstain: silence, not a false grant. * * A hook that deliberately exits 126/127 itself is missed the same way, and the * same direction. * * @internal Exported so the guard sweep asks the SAME question rather than * re-deriving it (one-detector-no-drift). It reads these codes for the opposite * purpose — not "may I credit this file with coverage?" but "may I score this * guard at all?" — and the answer is the same fact: the shell never reached the * program, so nothing the exit code says is the program's opinion. Not part of * the public API. */ export declare function shellNeverLaunched(status: number | null | undefined): boolean; /** * The run orchestration with injectable spawn seams: pick direct vs. confined * via the safe-by-default policy (`decideSandbox`), then assemble the result. * Exported so all three branches (direct / sandbox / refuse) are unit-tested * with fake spawners — no real bwrap. */ export declare function runScriptWith(command: string, stdin: string, opts: RunScriptOptions, deps: RunScriptDeps): ScriptRunResult; /** * Which of the three ways to start a script this run gets — or the refusal. * * 🔴 THE ONE PLACE THAT DECIDES, because a SECOND place that decided the same * thing got it wrong. `experimental_verifyPluginGuards` must know whether a run * will be confined BEFORE it runs anything: a confined run starts in a fresh * empty directory, so a relative script that exists here will not exist there, * and an interpreter that cannot open its script exits 2 — this harness's DENY * code — which is then scored as a block. It answered that question with its own * copy of the expression below, and the copy was a term short: `recordEgress` * selects the netns recorder and therefore confinement, and the copy did not * say so. So a `recordEgress` sweep pre-flighted against the host's cwd and env, * then ran somewhere neither existed. * * Returning the ROUTE rather than a boolean is what makes that unrepeatable: the * runner below dispatches on it and owns no policy of its own, and the pre-flight * asks the same function instead of re-deriving the answer. A future option that * selects confinement is added HERE, once, and every reader inherits it. * * A refusal counts as non-direct, and deliberately: the sweep is describing the * environment a run WOULD have, and the environment it would have refused to run * unconfined in is the confined one. */ export type ScriptRunRoute = { readonly kind: "egress"; } | { readonly kind: "sandboxed"; } | { readonly kind: "direct"; } | { readonly kind: "refuse"; readonly reason: string; }; /** What a run with these options will do, given what the machine can offer. */ export declare function routeScriptRun(opts: Pick, available: { readonly sandbox: boolean; readonly egress: boolean; }): ScriptRunRoute; export declare const REAL_DEPS: RunScriptDeps; export declare function egressRoutes(): boolean; /** * Run a program and report what it did — exit code, **both** streams, and (when * confined) its writes and egress. Synchronous, so it composes inside an eval's * `measure`. `command` goes through a shell, so the exact string a project * ships (args, env refs, pipes) works verbatim. * * This is the right tier for a plain helper script — a bash/node/python program * that isn't a hook. It has no decision to report, so the result carries none. * * ```ts * const r = runScript("bash scripts/check-links.sh", { cwd: repo }); * assert.equal(r.exitCode, 0); * assert.match(r.stderr, /0 broken links/); // advisory output lives here * ``` * * Running something you didn't write? Pass `trusted: false` (or `sandbox: * "auto"`) to confine it — which is also what records `filesWritten`. */ export declare function runScript(command: string, opts?: RunScriptOptions): ScriptRunResult; //# sourceMappingURL=run-script.d.ts.map