ADR-095
exec verb security architecture · layered defense-in-depth (permits = declaration, 3 layers enforce)
accepted · 2026-06-12 · L0.5 L1 L2 L3 · cites 7
0ADR-095 · exec verb security architecture #
Context #
The exec verb is the most dangerous of the 4 verbs: it runs OS processes
whose arguments are CEL-interpolated from upstream task outputs — including
infer/agent (LLM) outputs, which are attacker-influenceable by
construction (indirect prompt injection · arXiv 2302.12173). A deep audit
(2026-06-12) plus two primary-source research sweeps (arXiv agent-security
literature + 2026 competitor practice) found one active vulnerability and
several missing SOTA layers.
The audit findings #
- Finding · Severity · Was
- **Array form silently shell-joined** · P0 · The spec (02-verbs §exec) sells `command: ["prog", "{{ v }}"]` as "the STRUCTURAL fix for command injection". But the runtime joined the rendered argv with spaces and ran it through the OS shell (`ExecInput` carried only a `String`), so the *safe* form was injection-VULNERABLE while looking safe.
- No OS sandbox · P1 · The verb summary reads "exec: Shell command **with sandboxing**"; none existed. The child ran with the engine's full privileges/network/filesystem/env.
- Blocklist = denylist · P1 · A string-matching denylist that both leaks (∞ bypass variants) AND false-positives legit CI (`timeout`/`env`/`nice`/`printenv` blocked), re-scanning a flattened argv line that has no shell to parse.
- Env inherited wholesale · P1 · The child inherited the engine's full environment — the env-var-injection class (`LD_PRELOAD`/`BASH_ENV`/`GIT_SSH_COMMAND` …) and secret leakage.
- Grandchildren leak · P2 · `kill_on_drop` killed only the direct child; a backgrounded grandchild orphaned on timeout.
- `permits:` not enforced at exec · P1 · `Permits {fs,net,exec,tool}` is fully modeled and spec-mandated ("the engine MUST enforce on both surfaces" · NIKA-SEC-004), but the runtime sink never consulted it.
Research convergence (theory + practice agree — high signal) #
- Control/data-plane separation (CaMeL/DeepMind 2503.18813, design-patterns 2506.08837, Fides/MS 2505.23643): the workflow YAML is the trusted control plane; interpolated values are untrusted data that may fill declared slots but never select the program, flags, or DAG edges. A declarative DAG +
permits:give this almost for free. - Array-not-shell (GitHub Actions script-injection: 24,905 issues / 50 repos, arXiv 2208.03837; CVE-2025-30066): string-templating untrusted values into a shell is THE workflow-engine injection class.
- Default-deny symbolic policy (Progent 2504.11703, AgentSpec 2503.18666, AgentBound 2510.21236): an allowlist over binary + argv, deterministic, default-deny =
permits.exec. - OS sandbox floor (RedCode 2411.07781, SandboxEval 2504.00018; Claude Code / Codex / Cursor 2026): "assume the command is hostile" is uncontested; every coding-agent CLI ships an OS-level safe-by-default sandbox, all 100% local/OSS (sovereignty-aligned). Workflow engines (n8n/Airflow/Temporal) ship exec unsandboxed — Nika's chance to lead.
- Argument-injection canon (GTFOArgs, SonarSource/CAPEC-6): even without a shell, an argv value injects via flags (
ssh -o ProxyCommand,tar --checkpoint-action,git -c core.sshCommand,curl -K,find -exec). The--end-of-options discipline + a per-binary flag denylist. - Detection ⇒ telemetry only (2503.00061: 8/8 published detector defenses bypassed >50% under adaptive attack): a string-matching detector must never be the sole boundary.
Decision #
permits: is the declaration (the trusted control plane, author-written,
in-file · spec 01 §permits). Three layers enforce it; the blocklist
demotes from "the wall" to a shell-mode tripwire.
permits: { fs, net, exec, tool } DECLARATION · trusted · spec 01 §permits | enforced at 3 layers L2 nika-verb-exec — ExecCommand::{ Shell(String) | Argv(Vec<String>) } • argv = the structural injection fix (execve, no shell) [LAYER 1] • env scrub (deny LD_PRELOAD/BASH_ENV/… · reset PATH) [LAYER 3] L3 nika-runtime — dispatch the array form as a vector (no join) • permits.exec allowlist on the resolved program (NIKA-SEC-004) [LAYER 2] L0 nika-schema — one-obvious-way/008 steers string→array; the arg-injection rule-pack flags dangerous flags from interpolation [LAYER 2b] L1 nika-exec-runner — shell-mode blocklist tripwire · argv program-floor • process-group kill (reap grandchildren) · output cap · timeout [LAYER 4/5] L1 nika-sandbox — Landlock+seccomp (Linux) / Seatbelt (macOS): net-deny + FS-confine from permits.fs/net + rlimits [LAYER 6]The inversion: array (structural) + sandbox (OS) are the walls; the `permits.exec` allowlist is the intent gate; the blocklist is the shell-mode tripwire — not the sole, leaky, false-positive denylist it was.
The layers #
Layer 1 — Array execution, no shell (L2 + L3 · SHIPPED) #
ExecInput.command is now ExecCommand { Shell(String) | Argv(Vec<String>) }.
The array form maps to execve with the shell flag OFF; every element is one
literal argv token — an interpolated value can never break out of its argument
because there is no shell to parse it. The runtime renders each array element
and passes a vector (no join). The string form keeps the OS shell for genuine
pipelines. This closes the P0 and is the structural fix the spec mandates.
Layer 2 — permits.exec allowlist (L3 · SHIPPED) #
The runtime threads the workflow's Permits to the exec sink. exec: false
(or omitted under a declared boundary) refuses any process; a program
allowlist must contain the resolved leading token (argv[0], or the first word
of a shell line) — matching the static nika check (permits_fit) so a
workflow that passes the check never fails at runtime for a different reason.
The static check owns statically-detectable escapes; the runtime owns the
interpolated programs static analysis defers. A #[non_exhaustive] future
permit form fails CLOSED.
Layer 2b — Discoverability + arg-injection lints (L0 nika-schema) #
- one-obvious-way/008 (SHIPPED) warns when an interpolated value lands in a string command that needs no shell, pointing to the injection-safe array form — fires only when the literal parts carry no shell metacharacter (a genuine pipeline keeps the string form · low-false-positive).
- arg-injection rule-pack (DESIGNED · the differentiator) — a clean-room
catalog (from the public GTFOArgs + SonarSource/CAPEC-6 facts) of dangerous
flags per binary; flags an interpolated value landing in a flag slot / a
leading-dash value for a known binary, and recommends the
--end-of-options separator. No competing engine ships this.
Layer 3 — Env hygiene (L1 + L2 · SHIPPED · three sub-layers) #
Three sub-layers, weakest-to-strongest, each independent of the OS sandbox:
- POSIX-name validation (L2 · `nika-verb-exec`). An
env:key must match[A-Za-z_][A-Za-z0-9_]*— this refuses the dynamic-name carrier class, chiefly the exported-shell-function formBASH_FUNC_x%%that a bashsh -cchild imports and runs (the payload never appears in the scanned command string). Empty /=/ NUL keys still fail (a strict narrowing). - Dangerous-env denylist (L1 · `nika-exec-runner`). The runner ALWAYS
strips the injection vectors that grant code/library injection with no
dangerous flag:
LD_PRELOAD,DYLD_INSERT_LIBRARIES,BASH_ENV,GIT_SSH_COMMAND,PYTHONSTARTUP,NODE_OPTIONS,IFS, plusHOSTALIASES/TERMINFO(_DIRS)/TERMCAP(file-read / crafted-entry load) — independent ofpre_validated(the floor wins over an explicit set). - Ambient-secret scrub (L1 · `nika-exec-runner` ← composed by `nika-cli`).
The engine loads provider API keys from ITS env (
config_from_env) for its OWN inference calls; an exec child inherits them by ambient default and an untrusted command could exfiltrate them (printenv,cat /proc/self/environ). The composition root injects the provider-key NAMES (from the catalog) intoTokioShell, which strips each from every child UNLESS the workflow set it EXPLICITLY inenv:(explicit intent wins · only the ambient copy is removed). Least-privilege by default; empty scrub set = legacy behavior.
The stronger full clean-slate posture (drop ALL inherited env + a curated
PATH) still ships with the sandbox (Layer 6), which knows the confined
workflow's complete declared env needs; sub-layer 3 is the targeted
secret-scrub floor that holds before the sandbox is composed in.
Layer 4/5 — Process lifecycle (L1 · SHIPPED partial) #
The child spawns in its own process group (the safe std process_group(0), no
unsafe pre_exec); on timeout/cancel the whole group is signalled (SIGTERM →
grace → SIGKILL via nix::killpg) so detached grandchildren die. Output is
capped (64 MiB/stream) and the timeout hard-kills. Resource rlimits
(RLIMIT_CPU/FSIZE/NPROC + no-core-dump) need a child-side pre_exec (unsafe),
so they move to nika-sandbox (which owns the low-level confinement).
Layer 6 — OS sandbox (PER-PLATFORM L1 crates · macOS SHIPPED + verified) #
Reconciliation (2026-06-13). The kernel already RESERVES a capability
Sandbox (plugin::sandbox · WASM/MCP · the enter() self-restriction model
· v0.100), whose doc names the future impl crates nika-sandbox-landlock
(Linux) and nika-sandbox-seatbelt (macOS). Confining the exec verb's CHILD
process is a DISTINCT operation — "transform the command into its confined
form" — so it rides a NEW additive kernel seam, NOT the reserved trait, and
ships as the per-platform crates the reservation already names (operator-locked
2026-06-13: per-platform crates + a new trait · supersedes the original single
nika-sandbox framing of this ADR).
The seam (L0.5 · `nika-kernel-core/src/io/command_sandbox.rs` · SHIPPED).
A new CommandSandbox trait — sync, object-safe, confine(spec, ShellCommand) -> ShellCommand — plus SandboxSpec (fs_read/fs_write prefixes ·
allow_network · derived from permits.fs/net), CommandSandboxError
(Unavailable/Profile · fail-closed), and a NoopSandbox (the explicit,
auditable "unconfined" choice). Add a ShellCommand.sandbox field. The
wrapper model (no unsafe, no FFI · the seam returns a TRANSFORMED command,
not a pre_exec closure) replaces the original Confinement-closure design —
it keeps the crates at unsafe_code = forbid and is what every coding-agent CLI
uses.
macOS — `nika-sandbox-seatbelt` (SHIPPED · adversarially verified). Wraps a
command in the OS-shipped sandbox-exec launcher with a deny-default SBPL
profile generated from the spec: network denied unless granted; writes only
under declared fs_write + scratch; reads of the system paths every binary
needs, plus declared fs_read — so sensitive home paths (~/.ssh, ~/.aws)
are denied. Profile-injection via a malicious path is closed (control-char
refusal + quote-escaping). An adversarial jail suite RUNS sandbox-exec on
darwin and proves: a home-secret read is denied, a declared path is readable, a
write outside the allowlist is denied, an ordinary command still runs. Wired
into TokioShell::with_sandbox (applied after the blocklist · fail-closed).
Linux — `nika-sandbox-landlock` (CI-GATED). The sibling crate: landlock
(FS allowlist) + extrasafe/seccomp (block ptrace/unshare/AF_UNIX) + net-deny
- rlimits, via
bwrapor a Landlock helper. Only partly verifiable off a Linux box → a focused CI-backed arc. SameCommandSandboxseam.
Follow-on plumbing. Runtime derivation (permits.fs/net → SandboxSpec →
ExecInput.sandbox) and composer injection (select + inject the platform
backend into TokioShell) connect a workflow's permits: to confinement
end-to-end; the composer is the nika-cli/engine surface (in flight).
Honest limits (documented in the crate): glob→subpath is a coarsening (path- prefix, not per-file); network is allow-all-or-deny (host-granular needs a proxy); resource rlimits + the SandboxEval egress suite are part of the landlock arc. Full 12-gate admission (Linux backend · mutation ≥90% · review swarm) is the CI completion.
Layer 7 — Blocklist demoted to a tripwire (L1 · SHIPPED) #
The string-matching blocklist is no longer the wall. Shell mode keeps the full
battle-tested blocklist + the expansion-char refusal (the tripwire for the
genuinely-dangerous string path). Array mode gets an exact-basename
check_program against a small dangerous-program set (privilege escalation +
system control) — no fake-shell scan, which removes the timeout/env/nice/
printenv false-positives. Per the adaptive-attack evidence, the blocklist is
defense-in-depth, never the primary boundary.
Consequences #
Positive. The structural injection hole is closed; the safe form is the
default and discoverable; the capability boundary is enforced; the env / process
classes are shut; the sandbox (when shipped) makes "even if the blocklist
misses it, it can't hurt you" true. The architecture matches the converging
SOTA and stays 100% local/OSS (sovereignty). The permits: declaration unifies
all enforcement (the file is the security boundary).
Negative / interim gaps (logged, not silent).
- The argv program-floor no longer blocks a directly-authored dangerous command
(e.g. a destructive
rmon root) — that is gated bypermits.exec+ the sandbox, not the floor. Acceptable: the floor is identity-based; intent is policy + sandbox. - The OS sandbox (Layer 6) is the big remaining lift, cross-platform and only partly verifiable off a Linux box — a focused CI-backed admission arc.
- Runtime taint propagation (which resolved values are interpolation-derived)
is deferred — the arg-injection guard ships as a static lint; runtime
taint-enforcement lands when the binding layer carries taint (shadow zone
l1-taint-runtime). - Operator/host policy + token budgets remain
nika-policy's job (gated on the kernel migration); the runtimepermitsenforcement is the interim.
Alternatives considered #
- Blocklist-only (status quo). Rejected: both leaks and false-positives; the adaptive-attack literature refutes detector-as-boundary.
- Container/microVM per exec (e2b, Firecracker). Rejected as the default: non-sovereign (hosted) or heavy startup latency; the agent-CLI OS-sandbox model fits a local-first engine better.
- Sandbox as a module in nika-exec-runner. Rejected: heavy platform deps +
unsafe
pre_exec+ independent adversarial testing → its own L1 crate behind a kernel seam is the Diamond-coherent shape.
Implementation status #
- Layer · Crate(s) · Status
- 1 array execution · nika-verb-exec · nika-runtime · ✅ shipped
- 2 permits.exec · nika-runtime · ✅ shipped
- 2b /008 steer · nika-schema · ✅ shipped
- 2b arg-injection pack · nika-schema · ✅ shipped (arg-injection/001)
- 3 env POSIX-name validation · nika-verb-exec · ✅ shipped (kills `BASH_FUNC_x%%` carrier)
- 3 env dangerous-denylist · nika-exec-runner · ✅ shipped (+HOSTALIASES/TERMINFO/TERMCAP)
- 3 env ambient-secret scrub · nika-exec-runner ← nika-cli · ✅ shipped (provider keys · explicit `env:` wins)
- 3 env full clean-slate · nika-sandbox · 🔜 → L6 (drop-all + curated PATH)
- 4/5 process group · nika-exec-runner · ✅ shipped · rlimits → L6
- 6 sandbox seam · nika-kernel-core (CommandSandbox) · ✅ shipped
- 6 sandbox macOS · nika-sandbox-seatbelt · ✅ shipped · adversarially verified
- 6 sandbox wiring · nika-exec-runner (with_sandbox) · ✅ shipped
- 6 sandbox Linux · nika-sandbox-landlock · ✅ shipped · CI-gated (bwrap in the tests leg · 4 jail proofs)
- 6 permits→spec + composer inject · nika-runtime · engine · ✅ shipped (v0.105.x · dispatch derives the spec from `permits:` · the composer wires seatbelt/landlock · noop note when no backend)
- 7 blocklist tripwire · nika-exec-runner · ✅ shipped (mode-split)
References #
- Spec:
nika-spec spec/02-verbs.md §exec·spec/01-envelope.md §permits·spec/05-errors.md(NIKA-EXEC-001/002 · NIKA-SEC-001/004). - arXiv: 2302.12173 · 2208.03837 · 2503.18813 (CaMeL) · 2506.08837 · 2505.23643 (Fides) · 2504.11703 (Progent) · 2503.18666 (AgentSpec) · 2510.21236 (AgentBound) · 2411.07781 (RedCode) · 2504.00018 (SandboxEval) · 2503.00061 (adaptive attacks).
- Catalogs: GTFOArgs · SonarSource argument-injection-vectors (CAPEC-6) · CVE-2024-24576 (BatBadBut · Windows guard).
- Crates:
landlock0.4.5 ·extrasafe·nix0.30 · stdCommandExt::process_group. - Related: ADR-001 (CRAFT) · ADR-003 (12-gate) · ADR-016 (cancellation) · ADR-080 (mcp-stdio sandbox) · ADR-081 (L1 guard contract).
read at v0.107.0 · the decision record ships with the engine