Files
msd-core/hooks/lib/cli-exit.js
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

561 lines
31 KiB
JavaScript

// GENERATED FILE — DO NOT EDIT BY HAND.
// Source of truth: src/cli-exit.cts. Regenerate with:
// node scripts/gen-hooks-cli-exit.cjs --write
// Byte-compared by `npm run lint:generated-sync` (#3911, ADR-3889 Phase 7).
//
// Why this copy exists: hooks/ runs straight from a raw, unbuilt clone — a
// shipped hook must be able to `require('./lib/cli-exit.js')` relative to
// its own __dirname and terminate through `terminateNow` without depending
// on any build artifact. msd-core/bin/lib/cli-exit.cjs is gitignored tsc
// output and doubles as the build sentinel, so it cannot be required from
// here. `.js`, not `.cjs`, to match the hooks/lib/*.js convention. Hence one
// source, three emitted locations (msd-core/bin/lib, scripts/lib, hooks/lib).
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
/**
* Process-exit primitives (ExitError, runMain, terminateNow) plus the
* json-error-mode and contract-version cells.
*
* Must import nothing but `node:fs` and `./exit-code-registry.cjs` — this
* source is emitted to TWO locations, msd-core/bin/lib/cli-exit.cjs (tsc
* build output) and scripts/lib/cli-exit.cjs (a generated, committed
* artifact regenerated by scripts/gen-scripts-cli-exit.cjs), and the latter
* must load on an unbuilt clone before anything under ./lib exists. The
* registry require is safe here for the same reason: scripts/gen-exit-code-
* registry.cjs (ADR-3889 Phase 1/2, #3905/#3906) dual-emits its OWN sibling
* artifact, exit-code-registry.cjs, into both of these exact locations, so
* a relative `./exit-code-registry.cjs` resolves next to whichever copy of
* this module loaded it.
*/
const node_fs_1 = __importDefault(require("node:fs"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const exitCodeRegistryModule = require("./exit-code-registry.js");
// Called only as exitCodeRegistryModule.exitCodeFor(...), never destructured:
// @typescript-eslint/unbound-method flags a bare function-typed property
// pulled off an object at the point of destructuring, since a detached
// reference COULD be called with the wrong `this` — keeping the member
// access qualified sidesteps that regardless of whether the callee ever
// actually touches `this` (it does not; exitCodeFor is pure).
const exitCodeFor = (name) => exitCodeRegistryModule.exitCodeFor(name);
/**
* The wire value `runMain` stamps into its structured envelope. Declared HERE,
* not in io.cts, because this module must not import anything (see the module
* header): io.cts builds ERROR_REASON.SDK_FAIL_FAST from this constant, so the
* two surfaces share ONE definition rather than two literals kept in step by a
* parity test.
*/
const EXIT_ENVELOPE_REASON = 'sdk_fail_fast';
/**
* Process-level flag: when true, error paths emit structured JSON to stderr
* instead of plain text. Set by msd-tools.cjs when the CLI is invoked with
* `--json-errors`; re-exported by io.cts, which is where most callers reach it.
*
* Held in a Symbol-keyed cell on globalThis rather than in module scope, and
* that is load-bearing: this module is emitted to TWO locations
* (msd-core/bin/lib/cli-exit.cjs and the generated scripts/lib/cli-exit.cjs),
* so a process that loads both would get two independent module instances. A
* module-level `let` would give them two independent flags — one copy could
* think json mode is on while the other thought it was off, which is exactly
* the divergence class ADR-3889 exists to remove. One cell, keyed by a
* registry Symbol, makes that unrepresentable.
*/
const JSON_ERROR_MODE_KEY = Symbol.for('msd.exit.jsonErrorMode');
function setJsonErrorMode(v) {
globalThis[JSON_ERROR_MODE_KEY] = !!v;
}
function getJsonErrorMode() {
return globalThis[JSON_ERROR_MODE_KEY] === true;
}
/** The single registered name code 2 may ever be produced for (ADR-3889 §1). */
const HOOK_DENY_NAME = 'HOOK_DENY';
const HOOK_DENY_CODE = exitCodeFor(HOOK_DENY_NAME);
/**
* Currently-resolved exit-contract version (ADR-3889 §4). Held in a
* Symbol-keyed globalThis cell rather than a module-level `let`, for the
* exact reason JSON_ERROR_MODE_KEY is (see its comment above): this module
* is emitted to two locations and thus loaded as two independent module
* instances in any process that requires both, so a module-level variable
* would let those two instances disagree about which contract is active.
* `resolveContractVersion` is the only writer; `terminateNow`/`runMain`
* read it internally when projecting a declared outcome.
*/
const CONTRACT_VERSION_KEY = Symbol.for('msd.exit.contractVersion');
function setContractVersion(v) {
globalThis[CONTRACT_VERSION_KEY] = v;
}
/**
* Resolve the active exit-contract version, wiring the ambient process to the
* two terminators (ADR-3889 §4/§3). Mirrors how JSON_ERROR_MODE_KEY already
* works: a process-global cell means no entrypoint needs per-call wiring, so
* a `scripts/` tool or a hook gets the same behaviour as `msd-tools` without
* this module touching either (P8 owns `msd-tools`; P7 owns hooks).
*
* Precedence: if the cell already holds an explicit version, that wins —
* this is what lets `setContractVersion` override the ambient process (a
* later `MSD_EXIT_CONTRACT=v2` in the same process must NOT unseat an
* explicit `setContractVersion('v1')` call). Otherwise resolve from argv/env
* via `resolveContractVersion`, which itself persists the result into the
* cell — so this is a one-time resolution per process; every later read is
* just the cached cell value. An invalid ambient value (e.g. `v3`) is NOT
* softened to a silent v1 here: `resolveContractVersion` throws, and that
* throw propagates — swallowing it would reintroduce the "nothing fails with
* success" defect ADR-3889 exists to close, on the very selector meant to
* demonstrate the fix. Absent both flag and env, resolution still yields
* 'v1' (the documented default) and that too gets memoized.
*/
function getContractVersion() {
const cached = globalThis[CONTRACT_VERSION_KEY];
if (cached === 'v1' || cached === 'v2')
return cached;
return resolveContractVersion({ argv: process.argv, env: process.env });
}
/**
* Project a declared outcome onto an integer exit code for a given contract
* version. Pure and total over its own input space: throws for anything not
* an exact-case registered name (mirrors exitCodeFor's contract) or an
* unrecognized version — it never returns undefined/NaN.
*
* PASS/FAIL and every registered name project IDENTICALLY under v1 and v2
* (registered names are version-invariant) — the sole exception is DEGRADED:
*
* v1: DEGRADED -> 0. Deliberate, NOT a bug: ADR-2980 ratified 60
* `output({error})` call sites that already exit 0 on a payload-carried
* error, and ADR-2980's own "Revisit if" clause is what ADR-3889 §4
* answers — normalizing this to a non-zero code was explicitly
* DECLINED there on measured blast radius. A future reader must not
* "fix" this to look more consistent with v2; the inconsistency IS the
* compatibility boundary.
* v2: DEGRADED -> exitCodeFor('DEGRADED') (80). Looked up through the
* registry, never hardcoded, so a re-allocation of DEGRADED's code
* cannot silently desync this projection from the shipped table.
*/
function projectOutcome(outcome, version) {
if (typeof outcome !== 'string' || outcome.length === 0) {
throw new Error(`projectOutcome: outcome must be a non-empty string, received ${JSON.stringify(outcome)}`);
}
if (version !== 'v1' && version !== 'v2') {
throw new Error(`projectOutcome: version must be 'v1' or 'v2', received ${JSON.stringify(version)}`);
}
if (outcome === 'PASS')
return 0;
if (outcome === 'FAIL')
return 1;
if (outcome === 'DEGRADED')
return version === 'v1' ? 0 : exitCodeFor('DEGRADED');
// Any other registered name: version-invariant, resolved through the
// registry (throws for anything unregistered/empty/non-string/wrong-case —
// exitCodeFor's own contract, which this function inherits verbatim).
return exitCodeFor(outcome);
}
/**
* Pending declared outcome (ADR-3889 §4, #3912): the outcome `output()`
* records when it detects a payload-carried error (`{ error }`, any key
* order) on a call that otherwise just returns — there is no thrown
* ExitError and no explicit `main()` return for `runMain` to project, so
* without this cell the declaration has nowhere to land. `runMain` reads it
* ONLY when `main()` itself returns no explicit code (void/undefined); an
* explicit number/string return always wins over whatever this cell holds.
*
* Held in a Symbol-keyed globalThis cell for the exact reason
* JSON_ERROR_MODE_KEY / CONTRACT_VERSION_KEY are (see their comments above):
* this module is emitted to three locations and thus loaded as independent
* module instances in any process that requires more than one, so a
* module-level variable would let those instances disagree about whether a
* degraded result was ever declared.
*
* LIFETIME (#3912 review fix): LAST DECLARATION WINS, CLEARED ON CONSUMPTION.
* This cell is NOT "was DEGRADED ever declared this process" — it is "is a
* degraded outcome pending RIGHT NOW". `output()` sets it to 'DEGRADED' on a
* payload-carried error and CLEARS it (`undefined`) on a clean payload, so a
* later clean `output()` call undoes an earlier degraded one in the same
* invocation. `runMain` clears it immediately after consuming it (in a
* `finally`, on both the pending-cell branch and the case where nothing was
* pending), so a second `runMain` in the same process starts clean. Without
* both halves the cell is monotonic for the life of the process: any later
* `main()` returning void would inherit a stale DEGRADED from an unrelated,
* earlier call — this is the leak #3912 review found and fixed.
*/
const PENDING_OUTCOME_KEY = Symbol.for('msd.exit.pendingOutcome');
function setPendingOutcome(v) {
globalThis[PENDING_OUTCOME_KEY] = v;
}
function getPendingOutcome() {
return globalThis[PENDING_OUTCOME_KEY];
}
const EXIT_CONTRACT_FLAG_PREFIX = '--exit-contract=';
/** Scan argv for the FIRST `--exit-contract=<value>` token; undefined if absent. */
function findExitContractFlag(argv) {
for (const arg of argv) {
if (typeof arg === 'string' && arg.startsWith(EXIT_CONTRACT_FLAG_PREFIX)) {
return arg.slice(EXIT_CONTRACT_FLAG_PREFIX.length);
}
}
return undefined;
}
/**
* Resolve which exit-contract version is active from argv/env, per ADR-3889
* §4, and persist it to the shared contract-version cell so a later
* `terminateNow`/`runMain` call (through EITHER module copy) projects
* against it without re-parsing argv/env itself.
*
* Precedence: an explicit `--exit-contract=<v>` flag BEATS
* `MSD_EXIT_CONTRACT`, in both directions (flag=v1 + env=v2 -> v1; flag=v2 +
* env=v1 -> v2). Neither present -> 'v1' (the documented default). An empty
* env var reads as UNSET, not as an explicit empty selection — a shell that
* exports `MSD_EXIT_CONTRACT=` with nothing after the `=` must not silently
* select a version.
*
* Casing is decided, not accidental: only the exact lowercase tokens `v1`/
* `v2` are accepted (matching every example in ADR-3889 and this module's own
* usage docs, both of which write `v2` never `V2`). Anything else recognized
* as PRESENT but not a valid version — `v3`, `garbage`, or an explicitly
* empty flag value (`--exit-contract=`) — THROWS rather than silently
* defaulting to v1. A selector for a contract whose whole thesis is "nothing
* fails with success" must not itself fail open.
*/
function resolveContractVersion(opts = {}) {
const argv = opts.argv ?? process.argv;
const env = opts.env ?? process.env;
const flagValue = findExitContractFlag(argv);
const rawEnvValue = env.MSD_EXIT_CONTRACT;
const envValue = rawEnvValue === undefined || rawEnvValue === '' ? undefined : rawEnvValue;
const selected = flagValue !== undefined ? flagValue : envValue;
let resolved;
if (selected === undefined) {
resolved = 'v1';
}
else if (selected === 'v1' || selected === 'v2') {
resolved = selected;
}
else {
throw new Error(`resolveContractVersion: unrecognized exit-contract version ${JSON.stringify(selected)} `
+ `(expected 'v1' or 'v2')`);
}
setContractVersion(resolved);
return resolved;
}
/**
* Error carrying a process exit code. CLI logic throws this instead of calling
* process.exit() (banned by n/no-process-exit); runMain() translates it into
* process.exitCode at the entrypoint.
*/
class ExitError extends Error {
code;
hasUserMessage;
constructor(code = 1, message) {
super(message === undefined ? `process exit ${code}` : message);
this.name = 'ExitError';
this.code = code;
this.hasUserMessage = message !== undefined;
}
}
/**
* Run a CLI main and translate its outcome into process.exitCode (never
* process.exit, so n/no-process-exit stays satisfied; output flushes and
* process.on('exit') cleanup still fires — this is precisely why runMain and
* terminateNow are two different functions: drain-then-exit vs write-then-
* terminate). main may be sync or async. Every arm below except the new
* string one and the void/pending-cell one is UNCHANGED from before
* ADR-3889 Phase 2:
* number return -> process.exitCode = it (unchanged)
* string return -> NEW: process.exitCode = projectOutcome(result, getContractVersion()),
* UNLESS that projection is the HOOK_DENY exit code (see
* the refusal below — 2 may only be produced by terminateNow).
* void/undefined return -> NEW (#3912, ADR-3889 §4): an explicit return
* already handled above always wins, so this arm only
* runs when main() declared no outcome of its own. If
* the pending-outcome cell holds a value (currently only
* ever 'DEGRADED', set by io.cts's output() on a
* payload-carried error), project THAT through the
* current contract version — BUT ONLY when
* process.exitCode is not already a non-zero value.
* FULL PRECEDENCE ORDER for the code a void-returning
* main() ends up with:
* 1. An explicit number/string return from main()
* (handled in the arms above) — always wins.
* 2. A non-zero process.exitCode already set by main()
* itself before it returned (e.g. `state validate
* --strict`'s `emit()` setting 1 directly) — wins
* over the pending cell.
* 3. The pending-outcome cell's projection — used only
* when process.exitCode is still unset/0.
* 4. Otherwise process.exitCode stays 0 (default).
* This is a regression fix: unconditionally projecting
* the pending cell here used to CLOBBER an
* already-non-zero process.exitCode down to DEGRADED's
* v1 projection (0) — turning a real declared failure
* (e.g. `state validate --strict` against a missing
* STATE.md, which sets process.exitCode = 1 directly)
* into a false success. `runMain` must never LOWER an
* exit code that main() itself already raised. DEGRADED
* still projects to 0 under v1 when nothing else set a
* code — the same value this arm produced before this
* phase by doing nothing — so v1 behavior is
* byte-identical for every caller that never sets its
* own exit code.
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) (unchanged)
* other throw -> when json-error mode is active, emits structured { ok:false, reason, message }
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case. (unchanged)
*/
function runMain(main) {
Promise.resolve()
.then(() => main())
.then((result) => {
// Cleared on EVERY branch below, not only the pending-cell-consuming
// void arm: an explicit number/string return means main() declared
// its own outcome and the cell (if anything set it earlier in this
// same invocation) is now stale — leaving it set would leak into the
// NEXT runMain call in this process, reintroducing the #3912 leak one
// level up. "Cleared on consumption" therefore means "consumption of
// this runMain call", not just "consumption of the pending value".
try {
if (typeof result === 'number') {
process.exitCode = result;
return;
}
if (typeof result === 'string') {
const projected = projectOutcome(result, getContractVersion());
// ADR-3889 §3: exit code 2 (the hook-protocol deny) may
// ONLY be produced by terminateNow, never by runMain. runMain is
// drain-then-exit; a deny drained this way can be truncated on
// Windows, which is exactly why terminateNow (write-then-terminate)
// exists. Gated on the PROJECTED code, not on the literal string
// `'HOOK_DENY'`, so a future registry rename that still resolves to
// this code cannot slip past the guard.
if (projected === HOOK_DENY_CODE) {
process.stderr.write(`runMain: refusing to exit with code ${HOOK_DENY_CODE} — outcome ${JSON.stringify(result)} `
+ `projects to the ${HOOK_DENY_NAME} exit code, which is reserved to terminateNow. `
+ `A hook-protocol deny must be delivered write-then-terminate via terminateNow(${JSON.stringify(result)}, payload), `
+ 'never drain-then-exit via runMain — a drained deny can be truncated on Windows. '
+ 'This is a caller bug: runMain must not be given a main() that returns HOOK_DENY.\n');
process.exitCode = exitCodeFor('INTERNAL');
return;
}
process.exitCode = projected;
return;
}
// result is undefined (void return): main declared no outcome itself.
// Fall back to the pending-outcome cell, if anything set it — but
// NEVER lower an exit code main() already raised on its own (see the
// precedence order in this function's doc comment above). Without
// this guard, a void-returning main() that set process.exitCode = 1
// directly (e.g. `state validate --strict` on a missing STATE.md)
// would have that 1 clobbered down to DEGRADED's v1 projection (0)
// by a payload-carried error the SAME call also recorded via
// io.cts's output() — a real failure silently reported as success.
const pending = getPendingOutcome();
if (typeof pending === 'string' && pending.length > 0 && !process.exitCode) {
process.exitCode = projectOutcome(pending, getContractVersion());
}
}
finally {
// Cell is consumed exactly once per runMain call regardless of which
// branch above ran (see the cell's own doc comment — "last
// declaration wins, cleared on consumption") — so a later `runMain`
// in the same process never inherits this one's declaration.
setPendingOutcome(undefined);
}
})
.catch((err) => {
if (err instanceof ExitError) {
if (err.hasUserMessage && err.code !== 0)
process.stderr.write(`${err.message}\n`);
process.exitCode = err.code;
return;
}
if (getJsonErrorMode()) {
const e = err;
const payload = JSON.stringify({
ok: false,
reason: EXIT_ENVELOPE_REASON,
message: (e && e.message) ? e.message : String(err),
}) + '\n';
node_fs_1.default.writeSync(2, payload);
}
else {
const e = err;
process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`);
}
process.exitCode = 1;
});
}
/**
* Write `payload` fully to fd 1 (and, for a deny, fd 2 too) and terminate the
* process IMMEDIATELY with `outcome` projected through the current contract
* version. This is write-then-terminate, the other half of ADR-3889 §3's
* "two terminators over one registry": hooks fire from contexts (e.g. a
* `setTimeout` stdin-timeout guard) where `process.exitCode = N; return;`
* terminates nothing, so they need an immediate, synchronous exit — the
* exact gap `eslint.config.mjs:563-582` documents for `hooks/**`.
*
* This is THE ONLY sanctioned `process.exit` call site in the repo, and the
* only place exit code 2 can be produced: 2 is reserved to the hook-adapter
* protocol (ADR-3889 §1), and the registry's own one-owner rule already
* guarantees no other registered name resolves to it — the check below is a
* defense-in-depth assertion of that invariant, not the sole thing enforcing
* it.
*
* @param outcome - declared outcome name, projected via projectOutcome.
* @param payload - JSON-serializable value written to fd 1 (and, on a deny
* for which no `stderrPayload` is given, fd 2 too — this is the
* backward-compatible default every existing caller relies on).
* @param stderrPayload - optional, deny-only. When omitted (the default),
* fd 2 gets the SAME serialized `payload` fd 1 got — unchanged behavior.
* When provided, fd 2 gets THIS instead: a string is written raw
* (verbatim, not JSON-stringified), anything else is JSON-stringified
* like `payload`. This exists because `hooks/msd-write-guard.js`'s
* emitBlock does NOT write the same bytes to both streams today — it
* writes the full JSON `output` to stdout but only the plain-text
* `output.reason` STRING to stderr, because a native hook bus may read
* stderr verbatim back to the model on exit 2. Migrating that call site
* onto terminateNow requires a way to say "fd 2 gets this different,
* plain-text value" — `stderrPayload` is that seam. Ignored entirely for
* a non-deny outcome: stderr is a deny-only channel.
*
* PAYLOAD-SIZE CONSTRAINT FOR CALLERS (measured for #3906, relevant to P7/
* #3911 wiring 19 enforcement hooks onto this function): the write-until-
* drained loop above delivers a payload whole regardless of size — verified
* up to 1MB (Node's own `spawnSync` default `maxBuffer`) with no truncation
* and no stall, both with a concurrently-draining async reader (~30ms for a
* 256KB payload) and with the default (internally-drained) pipe stdio a
* spawnSync-based test harness gets for free. Node's `spawnSync` does NOT
* suffer the classic "child blocks writing past the pipe buffer because
* nothing on the parent side is reading yet" deadlock some other languages'
* synchronous-subprocess primitives have; it drains stdout/stderr
* concurrently at the libuv layer while the child runs. The constraint that
* DOES bite on Linux is unrelated to pipe buffering: `execve(2)` enforces
* `MAX_ARG_STRLEN` (128KiB per single argv/envp string; see `man execve`
* NOTES) — so a CALLER that embeds a large literal payload directly into a
* spawned command line (e.g. `node -e "...<huge string>..."`) can fail to
* even start the child on Linux (macOS has no equivalent per-string cap),
* with no relation to this function's own behavior. See
* tests/cli-exit.test.cjs's "a large payload (bigger than a pipe buffer)
* arrives whole" test, which hit exactly this constructing its own fixture
* before being rewritten to build the payload inside the child instead.
*/
function terminateNow(outcome, payload, stderrPayload) {
// terminateNow is total by construction: its callers are enforcement hooks
// (P7/#3911, 19 of them) whose OWN outer catch may fail open (some end in
// `process.exit(0)`). If resolving the contract version, projecting the
// outcome, or the HOOK_DENY-collision guard below threw and that throw
// propagated out of this function, it would unwind straight into that
// caller's catch — turning a deny into a silent allow, exactly the defect
// ADR-3889 exists to close. So every one of those steps is wrapped here:
// on ANY failure this still terminates, deterministically, with INTERNAL
// (never by returning or re-throwing) — a malformed call is a programming
// error to be diagnosed on stderr, not a reason to hand control back.
let versionForDiagnostics = '(unresolved)';
try {
const version = getContractVersion();
versionForDiagnostics = version;
const projected = projectOutcome(outcome, version);
if (projected === HOOK_DENY_CODE && outcome !== HOOK_DENY_NAME) {
throw new Error(`terminateNow: exit code ${HOOK_DENY_CODE} is reserved to the ${HOOK_DENY_NAME} outcome; `
+ `got outcome ${JSON.stringify(outcome)}`);
}
// m2 (round 5, hooks/msd-write-guard.js:159-175): emission must itself be
// exception-safe. A failed write (EPIPE, a full pipe buffer, a throwing
// fs.writeSync in a test) must NOT change the exit code — if it propagated
// out of this function, a caller whose payload could not be delivered
// would fall into ITS OWN outer catch and fail OPEN, which is the exact
// outcome the fail-closed branches this function serves exist to prevent.
// The decision to terminate with `projected` stands regardless of whether
// the payload could be delivered.
//
// The two streams are emitted in TWO SEPARATE try/catch blocks, not one
// shared block (the pre-#3911 defect): fd 1 and fd 2 (deny-only) are
// independent channels with independent failure modes, and a shared try
// meant a serialization failure on fd 1 (e.g. `payload` throwing on
// JSON.stringify) aborted the block before fd 2 ever ran — silently
// dropping a deny's reason. `deny(undefined, 'some reason')` used to exit
// 2 with EMPTY stderr because of exactly this. Each block independently
// treats an `undefined` value for ITS OWN stream as "nothing to write"
// and skips the write cleanly, rather than serializing `undefined` (which
// is not valid JSON text) and throwing into the catch.
try {
// fs.writeSync, never process.stdout.write: pipe writes via
// process.stdout/stderr are async on Windows, and process.exit() below
// does not wait for them to flush — a truncated payload is a silent
// half-emission. Looped over a Buffer (not a bare string call) so a
// payload larger than the destination pipe's buffer — where a single
// write() syscall can legitimately return fewer bytes written than
// requested — still arrives whole rather than truncated.
if (payload !== undefined) {
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
let offset = 0;
while (offset < buf.length) {
offset += node_fs_1.default.writeSync(1, buf, offset, buf.length - offset);
}
}
}
catch {
// fd 1 emission failed; the exit code decision still stands (see
// above), and fd 2 below is unaffected — it has its own try/catch.
}
if (projected === HOOK_DENY_CODE) {
try {
// Backward-compatible default: when no `stderrPayload` is given, fd 2
// gets the SAME value fd 1 got (still subject to fd 2's own
// undefined-skips-the-write and string-vs-JSON rules below).
const resolvedStderr = stderrPayload === undefined ? payload : stderrPayload;
if (resolvedStderr !== undefined) {
const stderrBuf = Buffer.from(typeof resolvedStderr === 'string' ? resolvedStderr : JSON.stringify(resolvedStderr), 'utf8');
let stderrOffset = 0;
while (stderrOffset < stderrBuf.length) {
stderrOffset += node_fs_1.default.writeSync(2, stderrBuf, stderrOffset, stderrBuf.length - stderrOffset);
}
}
}
catch {
// fd 2 emission failed; independent of fd 1 above, and the exit code
// decision still stands regardless.
}
}
// n/no-process-exit is not registered for src/**/*.cts (see the ADR-3889
// reference note in the module header) and both compiled .cjs copies of
// this module are lint-ignored build/generated artifacts, so no disable
// directive is needed here for the one sanctioned process.exit call site.
process.exit(projected);
}
catch (err) {
// Anything above threw: an unrecognized --exit-contract/MSD_EXIT_CONTRACT
// value, a non-string/empty/unregistered `outcome`, or the HOOK_DENY
// collision guard. Diagnose on stderr — swallowing this silently would
// make a typo'd outcome name or a bad contract-version env var
// undebuggable — then terminate unconditionally. The diagnostic write
// itself gets its own swallow-on-failure guard, because even a failed
// diagnostic must not stop the exit below from happening.
try {
const detail = err instanceof Error ? err.message : String(err);
const message = `terminateNow: programming error — outcome=${JSON.stringify(outcome)} `
+ `version=${JSON.stringify(versionForDiagnostics)}: ${detail}\n`
+ `This is a caller bug (unrecognized outcome/exit-contract, or the HOOK_DENY collision `
+ `guard), not a declared outcome. Terminating with INTERNAL rather than propagating: an `
+ `enforcement-hook caller's own outer catch may fail open (process.exit(0)), and unwinding `
+ `into it here would silently convert a deny into an allow.\n`;
node_fs_1.default.writeSync(2, message);
}
catch {
// Diagnostic emission itself failed; the exit below is unconditional
// regardless.
}
process.exit(exitCodeFor('INTERNAL'));
}
}
module.exports = {
ExitError,
runMain,
setJsonErrorMode,
getJsonErrorMode,
EXIT_ENVELOPE_REASON,
projectOutcome,
resolveContractVersion,
getContractVersion,
terminateNow,
setPendingOutcome,
getPendingOutcome,
};