Skip to content

feat(core): spawn_confined_subprocess — confine an arbitrary child under Caveats#56

Merged
hartsock merged 1 commit into
mainfrom
issue-55/spawn-confined-subprocess
Jun 25, 2026
Merged

feat(core): spawn_confined_subprocess — confine an arbitrary child under Caveats#56
hartsock merged 1 commit into
mainfrom
issue-55/spawn-confined-subprocess

Conversation

@hartsock

Copy link
Copy Markdown
Member

Fixes #55.

Summary

Adds the missing primitive to put an arbitrary subprocess under the leash —
the keystone for running gila capabilities as confined gilacap subprocesses
(the deputy-crosses-a-process-boundary case).

New agent-bridle-core::spawn module:

  • ConfinedCommand — a std::process::Command-like builder whose
    environment starts empty (only vars added via .env() reach the child) and
    whose .spawn(&cx):
    1. admission-checks cx.check_exec(program) — deny before doing anything;
    2. fails closed — if fs_write is restricted (Only(..)) but no OS sandbox
      can enforce it, refuse rather than spawn unconfined;
    3. applies best_available_sandbox() on a fresh throwaway thread, then
      spawns on it, so the child (and its descendants) inherit the Landlock
      fs_write domain via fork/exec inheritance — then the thread exits, leaving
      the caller's threads unrestricted.
  • spawn_confined_subprocess(program, args, cx, env_allow, cwd) — the
    convenience free function from the issue (inherited stdio).
  • ConfinedChild { child, sandbox_kind } — the caller owns the Child
    (wait/kill/pipe), and sandbox_kind is the honest record of what confinement
    was achieved.

OCAP invariants upheld: attenuate-before-spawn / no self-confinement (I3), env
scrub so secrets never move ambiently (I12), absence-of-enforcement-denies (I5).
#![forbid(unsafe_code)] preserved — no pre_exec; the thread + restrict_self
pattern mirrors sandbox.rs.

Scope honesty: only fs_write is L3-enforced today, so it is the sole axis in
the fail-closed decision; fs_read/exec/net confinement of a subprocess is
advisory until those backends land (net needs netns/seccomp — ADR 0001/0006). The
PR docs say so rather than overclaim.

Test plan

just check green (fmt + clippy -D warnings ×{all-features, no-default-features}

  • tests ×both; coverage 83.5% ≥ 75% floor). New tests:
  • exec_outside_scope_is_denied_before_any_spawn (cross-platform)
  • environment_is_scrubbed_to_the_granted_allow_list — parent HOME does not
    leak; the granted var is present (unix)
  • restrictive_write_refused_when_no_sandbox_available — fail-closed (runs on the
    no-landlock build)
  • unenforceable_predicate_only_trips_on_restricted_write_without_sandbox (pure)
  • child_inherits_fs_write_domain_…kernel test: the spawned child's
    out-of-scope write is EACCES-denied while an in-scope write succeeds
    (Linux + linux-landlock; verified on 6.8)

Follow-ups (out of scope)

  • stdio is configurable via ConfinedCommand (piped for MCP); the free fn uses
    inherited stdio.
  • net/fs_read/exec L3 confinement of the child (netns/seccomp; ADR 0006).
  • The consumer wiring (gilamonster-agent gila cap run → mint per-cap caveats via
    newt_identity::delegate_for_plugin → spawn gilacap confined) lands in the
    gilamonster-capabilities repo.

risk:high — security boundary primitive.

…der Caveats

WHAT: New `spawn` module in agent-bridle-core: `ConfinedCommand` (a Command-like
builder) and the `spawn_confined_subprocess` convenience fn, returning a
`ConfinedChild { child, sandbox_kind }`. It admission-checks `exec`, clears the
environment (only explicitly-granted vars reach the child), and applies the best
available OS sandbox on a fresh throwaway thread before spawning — so the child
(and its descendants) inherit the Landlock `fs_write` domain via fork/exec
inheritance. Fails closed: a restrictive `fs_write` that no OS sandbox can
enforce is refused rather than spawned unconfined.

WHY (Fixes #55): bridle confinement was hardwired to the in-process brush shell;
there was no way for a host to put an *arbitrary* subprocess under the leash. The
gila capabilities run as `gilacap` Python subprocesses spawned by the
gilamonster-agent harness — a deputy crossing a process boundary, exactly the
confused-deputy case. This is the keystone primitive: the parent attenuates
before spawn (the child is never trusted to confine itself; I3), env is scrubbed
(secrets never move ambiently; I12), and absence of enforcement denies (I5).

Honest about scope: only `fs_write` is L3-enforced today, so it is the sole axis
in the fail-closed decision; `fs_read`/`exec`/`net` confinement of the child is
advisory until those backends land (net needs netns/seccomp; see ADR 0001/0006).

Tests: exec denied before spawn; env scrubbed to the allow-list (parent HOME does
not leak); fail-closed refuse when no sandbox (no-default-features build); and a
Linux+landlock kernel test proving the SPAWNED CHILD's out-of-scope write is
kernel-denied while an in-scope write succeeds. `#![forbid(unsafe_code)]` upheld —
no pre_exec; the thread+restrict_self pattern mirrors sandbox.rs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018R92DDLCsWorb4fSJQrdvD
@hartsock hartsock added the risk:high High-risk change label Jun 25, 2026
@hartsock hartsock merged commit f3382a8 into main Jun 25, 2026
1 check passed
@hartsock hartsock deleted the issue-55/spawn-confined-subprocess branch June 25, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:high High-risk change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add spawn_confined_subprocess: confine an arbitrary child under Caveats (Landlock + env-scrub)

1 participant