/** * The child-process trust boundary (ES-P0-ACTION-CREDENTIAL-SCOPE). * * Every CLI child that executes repository- or pull-request-controlled content * — the semantic agents (`agents run`), the headless workflow steps * (`orchestrate workflow run`), the journey suites (`journey run`) — used to * inherit the COMPLETE parent environment through `shell: true`. In CI that * parent holds the model-vendor key, the license key, the Actions OIDC request * token, and whatever else the workflow author exported; the child reads * attacker-influenced content by design (that is its job), so a prompt * injection was one `env` away from every credential on the runner. A prompt * rule, a timeout, and a post-run `git status` do not make a boundary. * * This module is the boundary. Four mechanisms, each enforced in code: * * 1. **Environment allowlist** (`buildChildEnv`): a child receives ONLY the * names in `CHILD_ENV_ALLOWLIST` (+ `LC_*`/`XDG_*`), plus what the caller * sets explicitly, plus names the operator passed through by NAME with * `--pass-env`. Everything else is absent — not empty, absent. A small set * of names (`HARD_DENY`) can never be passed through at all: the GitHub * token, the Actions OIDC/runtime tokens and file-command paths, the * Enterprise Skills license/service keys, npm auth. Those belong to the * control-plane operations (`govern --post`, `journey` posting), which are * separate processes from PR-content analysis and never children of it. * 2. **Trust classification** (`classifyTrust`): where is this process running * and whose content is it about to execute? Local operator, trusted CI * event, same-repository PR from a write-access author, or untrusted PR * content (fork, `pull_request_target`, non-member author, unreadable * payload, unrecognised event — all fail closed to untrusted). * 3. **Execution gate** (`decideChildExecution`): untrusted content does NOT * execute unless an ENFORCEABLE filesystem AND network isolation provider is * active for the child (`detectIsolation`) — and no such provider exists on * any runtime this CLI ships for, so untrusted content is refused outright * with the stable code `ES_UNTRUSTED_ISOLATION_UNAVAILABLE`. Environment * scrubbing is not isolation, and a printed warning is not containment * (RH-20260820-003V finding 2). Independently of isolation, untrusted * content with any credential-like variable in scope or any pass-through is * refused; hard-denied names are refused everywhere. The decision is made * BEFORE any sandbox is created or process spawned. * 4. **argv, not a shell line** (`parseCommandTemplate`, `resolveSpawnTarget`): * the agent/journey command is tokenised with POSIX quoting rules and * spawned with `shell: false`; unquoted shell metacharacters and `$` * expansions are refused, `{prompt}` becomes exactly one argument, and a * Windows `.cmd` shim is resolved to its node entry rather than routed * through cmd.exe (where `%VAR%` expansion re-scans values). * * What this module cannot do, stated rather than implied: it does not isolate * a TRUSTED child's NETWORK (a semantic agent needs vendor egress) and it does * not isolate a trusted child's FILESYSTEM beyond the evidence sandbox (see * evidence-sandbox.ts) — a trusted child runs as the same OS user as the * runner, on content the operator or a write-access author chose. Those limits * are exactly why UNTRUSTED content does not run at all. */ /** Names copied from the parent environment when present. Nothing else is. */ export declare const CHILD_ENV_ALLOWLIST: readonly string[]; /** Prefix families copied from the parent when present. */ export declare const CHILD_ENV_ALLOWLIST_PREFIXES: readonly string[]; /** * Names that can NEVER reach a child, even by explicit `--pass-env`. They are * the runner's and the control plane's own identity; no repository-content * analysis has a legitimate use for them. Matched case-insensitively — Windows * resolves environment names case-insensitively, so a cased alias of a denied * name IS the denied name (see HARD_DENY_NAMES_UPPER). */ export declare const HARD_DENY_NAMES: readonly string[]; /** Prefix families that are hard-denied (OIDC/runtime tokens, action inputs, npm auth config). */ export declare const HARD_DENY_PREFIXES: readonly string[]; export declare function isHardDeniedName(name: string): boolean; export declare function isCredentialLikeName(name: string): boolean; export declare function isAllowlistedName(name: string): boolean; /** Names in `env` that are credential-like (by name; values are never inspected). */ export declare function credentialNamesInScope(env: NodeJS.ProcessEnv): string[]; export interface BuildChildEnvOptions { parent: NodeJS.ProcessEnv; /** Names the operator explicitly passes through (`--pass-env`). */ passThrough?: readonly string[]; /** Values the CLI itself sets (prompt, coordinates). Always win. */ extra?: Record; } export interface ChildEnvResult { env: Record; /** Allowlisted names actually copied. */ copied: string[]; /** Pass-through names actually copied (present in the parent). */ passed: string[]; /** Pass-through names requested but absent from the parent (informational). */ passedAbsent: string[]; /** Parent names dropped because they are not allowlisted. */ strippedCount: number; } /** * Build the child environment. Throws on a hard-denied pass-through — that is * a configuration the operator must fix, not a warning to scroll past. */ export declare function buildChildEnv(options: BuildChildEnvOptions): ChildEnvResult; export type TrustLevel = /** Not GitHub Actions: an operator's own machine running on content they chose. */ 'local' /** A CI event on the repository's own refs (push, dispatch, schedule, merge_group, release). */ | 'ci-trusted' /** A same-repository pull_request authored by someone with write access. */ | 'ci-same-repo-pr' /** PR content that must not execute with credentials: fork, pull_request_target, non-member author, unreadable payload, unrecognised event. */ | 'ci-untrusted'; export interface TrustClassification { level: TrustLevel; reason: string; event: string | null; repository: string | null; headRepository: string | null; authorAssociation: string | null; } /** * Classify the execution context from the GitHub Actions environment and the * event payload. Everything that cannot be positively classified as trusted * is untrusted — an unreadable payload is not "probably fine". */ export declare function classifyTrust(params: { env: NodeJS.ProcessEnv; readFile?: (p: string) => string; }): TrustClassification; /** * What the runtime can ENFORCE around a child — not what the CLI can detect * afterwards. `filesystem` means the child cannot write outside a designated * sandbox (real checkout, real HOME, the canonical evidence directory are * unreachable, not merely diffed); `network` means egress is denied by the OS * or a supervising boundary, not by an environment variable the child could * ignore. Both must be true for untrusted content to execute. */ export interface IsolationCapability { filesystem: boolean; network: boolean; /** The enforcing provider's name, or null when nothing enforceable is active. */ provider: string | null; detail: string; } /** * The honest answer for every runtime this CLI ships for today: the child is * a same-user process with the runner's filesystem and egress. There is no * bubblewrap/seccomp/firejail/job-object-with-network-filter integration, and * the CLI does not pretend one by scrubbing `HTTP_PROXY`. Until a provider * exists and is verified, this constant is what `detectIsolation` returns, and * untrusted content is therefore refused (`ES_UNTRUSTED_ISOLATION_UNAVAILABLE`). */ export declare const NO_ISOLATION: IsolationCapability; /** * Detect an enforceable isolation provider for child execution. Deliberately * returns NO_ISOLATION on every platform today: environment-variable scrubbing, * a working-directory sandbox, and post-run diffs are NOT isolation, and this * function does not dress them up as one. A future provider (e.g. bubblewrap * with `--unshare-net` on Linux) plugs in here and must be proven before it * reports true. */ export declare function detectIsolation(_env?: NodeJS.ProcessEnv): IsolationCapability; /** Stable, documented refusal codes (printed with every refusal; `--dry-run` exits 1 with them). */ export type ChildRefusalCode = /** A `--pass-env` name is hard-denied (runner identity, control-plane key, npm auth). Any trust level. */ 'ES_PASS_ENV_HARD_DENIED' /** Untrusted content and no enforceable filesystem+network isolation provider is active. */ | 'ES_UNTRUSTED_ISOLATION_UNAVAILABLE' /** Untrusted content with a `--pass-env` requested. */ | 'ES_UNTRUSTED_PASS_ENV' /** Untrusted content with a credential-like variable in the parent environment. */ | 'ES_UNTRUSTED_CREDENTIALS_IN_SCOPE'; export interface ChildExecutionDecision { allowed: boolean; reason: string; /** The primary refusal code (null when allowed). */ code: ChildRefusalCode | null; /** Every reason the run is refused, most fundamental first (empty when allowed). */ blockers: Array<{ code: ChildRefusalCode; detail: string; }>; trust: TrustClassification; /** Credential-like names present in the parent environment (by name). */ credentialsInScope: string[]; passThrough: string[]; isolation: IsolationCapability; } /** * Decide whether a child may execute at all — BEFORE a sandbox or process * exists. A hard-denied pass-through is refused at every trust level. * Untrusted content is refused unless an enforceable filesystem+network * isolation provider is active (none is, today), and independently refused * while any credential-like variable is in scope or any pass-through is asked * for. Every blocker is reported, so an operator fixing one does not discover * the next one on the following run. */ export declare function decideChildExecution(params: { trust: TrustClassification; parentEnv: NodeJS.ProcessEnv; passThrough?: readonly string[]; /** Injectable for tests; defaults to detectIsolation(). */ isolation?: IsolationCapability; }): ChildExecutionDecision; export type ParsedTemplate = { ok: true; argv: string[]; } | { ok: false; error: string; }; /** * Tokenise a command template with POSIX-sh quoting (single quotes literal, * double quotes with backslash escapes, backslash outside quotes escapes the * next character). There is NO shell at spawn time, so anything a shell would * interpret — pipes, redirects, substitutions, `$VAR` — is refused outright * rather than silently passed as a literal that used to mean something else. * The legacy `$ES_AGENT_PROMPT` / `${ES_AGENT_PROMPT}` spellings are accepted * as aliases of `{prompt}` for existing workflow files. */ export declare function parseCommandTemplate(template: string): ParsedTemplate; /** * Substitute `{name}` placeholders token-by-token. A placeholder becomes part * of exactly the argument it appears in — never re-tokenised, never seen by a * shell — so the prompt (which embeds repository-derived text) cannot add, * split, or alter arguments. */ export declare function substituteArgv(argv: readonly string[], values: Record): string[]; export type SpawnTarget = { ok: true; file: string; prefixArgs: string[]; } | { ok: false; error: string; }; /** * Resolve argv[0] to something `spawn(file, args, { shell: false })` can start. * A Windows `.cmd`/`.bat` shim cannot be started without a shell, and routing * it through cmd.exe re-introduces `%VAR%` expansion (cmd re-scans expanded * values for metacharacters — the exact property the env-var prompt transport * relied on NOT holding). npm's generated shim is parsed for its node entry * and spawned as `node `; any other batch file fails closed with the * remedy in the message. */ export declare function resolveSpawnTarget(command: string, env?: NodeJS.ProcessEnv): SpawnTarget; /** One-line, operator-facing summary of a boundary decision (names only, never values). */ export declare function describeBoundary(decision: ChildExecutionDecision, env: ChildEnvResult): string[]; //# sourceMappingURL=child-boundary.d.ts.map