Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR 0035: Agent Execution Isolation & Pluggable Enforcement Backends

Status: Proposed Date: 2026-08 Ticket: AAASM-5703 (Epic AAASM-5702)

This ADR defines how Agent Assembly turns a governed launch into an evidence-backed execution boundary for an untrusted AI-agent process tree. It makes aasm run the canonical managed-execution front door, separates whole-agent native isolation from the existing WASM/WASI tool sandbox, and establishes a backend-neutral contract so one AASM policy can be lowered into different enforcement substrates without making any substrate the policy model.

It complements and does not supersede ADR 0033. ADR 0033 remains the canonical source for the six architectural elements and for truthful claims about decision timing and evidence. This ADR occupies the execution-specific part of that model:

  • E2 · Managed Execution Checkpoints — aasm run resolves the launch and its required capabilities before the untrusted process starts.
  • E4 · Platform-Specific Host-Level Interception Adapters — a concrete isolation backend realizes the OS/runtime-specific restrictions it actually supports.
  • E5 · Credential / Capability Boundary — the launched process receives only the ambient authority and delegated capabilities its execution plan permits.
  • E6 · Evidence & Protection-State Pipeline — the product reports requested, planned, achieved and unmeasured controls separately; backend availability is never evidence of enforcement by itself.

Cross-reference — AAASM-6160 (2026-09): ADR 0038 extends this ADR’s ControlRequirement/negotiate contract with a question this ADR does not ask — was a run ever explicitly authorized to touch a domain at all, independent of whether the selected backend can mechanically enforce it. It cross-references rather than amends: nothing decided here is reversed.

The existing aa-sandbox remains the WebAssembly/WASI sandbox for individual WASM-marked tool executions. This ADR defines a different boundary: confinement and supervision of the agent’s native process and descendants. The two mechanisms may be composed but must never share a name or claim merely because both use the word “sandbox”.


Amendment — AAASM-5751 (2026-08-14): filesystem path scope becomes policy-expressible

Scope of this amendment: one decision below gains a concrete policy node, and one recorded fact about the schema is corrected. No decision is reversed or superseded.

Why an amendment rather than a new ADR

The principle was already decided here. Backend capability examples states that the capability vocabulary must distinguish “Filesystem | read/write/create/delete scope”, and decision 3 states that policy describes required isolation properties portable across backends. Adding a filesystem path-scope node executes a decision this ADR already made; recording it as a new ADR would fork the record for a question that is already answered.

It is not “no ADR action” either. Until AAASM-5751, no policy node could name a path, so aa_isolation::lowering reported both filesystem domains at ScopeGranularity::WholeDomainOnly and its module documentation said the schema carried “no path scope”. Shipping the node makes that recorded text false, and a decision record that has gone stale is worse than one that never existed.

It amends this ADR and not ADR 0033: 0033 stays the canonical source for the §6 claim vocabulary, which this amendment does not touch, and it carries a separate open amendment under AAASM-5654. Two concurrent edits to 0033 would conflict for no benefit — nothing here needs a §6 term that does not already exist.

What is now expressible

A filesystem: section on the policy spec, with an operator-authored allow-list of absolute path prefixes per verb:

spec:
  filesystem:
    read:
      allow: ["/workspace", "/usr/share/dict"]
    write:
      allow: ["/workspace/build"]

Normative reading of the three states, which must not collapse into one:

Authored formMeaningLowers to
Section absentThe operator stated nothing about paths. Not a grant.DomainCoverage::NotStated; no requirement from this node
allow: present and non-emptyOnly these subtrees may be reached; everything else must be preventedLowered { granularity: Enumerated }, RequirementScope::Selectors
allow: present and emptyA restriction is in force and permits nothing — the most restrictive postureLowered { granularity: WholeDomainOnly }, RequirementScope::Whole

An empty allow: deliberately reads as deny-all rather than as “no restriction”. That matches aa_policy::check_network_egress’s live treatment of an empty egress allowlist and refuses to repeat the canonical NetworkPolicy doc-comment’s opposite reading, which is the fail-open shape AAASM-3728/AAASM-3730 already had to close once.

A whole-domain capability denial (capabilities.deny: [file_read]) outranks the path allow-list, exactly as a network_outbound denial outranks network.allowlist: lowering the narrower node under a broader denial would emit a requirement permitting paths the same document forbids.

Across a policy cascade the merge is most-restrictive-wins: the effective permitted set is the intersection of every tier that declared the node, computed over path subtrees rather than over strings, so a narrower tier can only ever shrink a broader one. A cascade carrying no documents at all is a refusal, not an empty intersection — ADR 0024 §6(2) already settled that an absent cascade is Unconfigured, never permission.

What deliberately remains unrepresentable

NameResolution, Ipc, Credential and Resource keep reporting DomainCoverage::PolicyCannotExpress. This is recorded as accepted, measured risk, not as a set of domains nothing could use: measured against the Sandlock backend’s capability.rs, Ipc and Credential are Mediation::Enforce with SupportLevel::Partial and Resource is Mediation::Enforce, so for those three the backend can enforce something an operator has no way to ask for. They stay unexpressed because no acceptance criterion under Epic AAASM-5702 requires operator-authored control of them, and ambient credential authority is handled procedurally by AAASM-5709 rather than as a policy node. Speculative schema is a public contract that cannot be withdrawn; a named gap can be closed at any time.

Two sources of paths that already exist in the codebase were examined and rejected as policy sources:

  • SENSITIVE_MUTATION_DENY_DEFAULTS (aa-security/src/policy/ebpf.rs) is the eBPF layer’s own implementation floor, not something an operator wrote. Consuming it would make one backend’s lowering the policy semantics, which decision 2 forbids.
  • tools[].requires_approval_if path prefixes are operator-authored, but they mean approval, not denial. A confined process has no approval channel, so lowering them to a prevention requirement would silently convert “ask a human” into a hard block. Stronger than what the operator wrote is still wrong.

Consequence for existing text

Where this ADR and code derived from it describe filesystem policy as a whole-domain boolean, that description is superseded for path scope only. The remaining filesystem gaps this ADR names are unchanged and still reported per domain: create/rename/write and delete are not separable verbs, and the backend’s own sensitive-path defaults are still not re-derived as policy.


Amendment — AAASM-5801 (2026-08-20): the AASM-native backend becomes the second IsolationBackend implementor

Scope of this amendment: it names one concrete backend, records the Linux primitives and process shape it uses and why, and records what it deliberately does not attempt. It reverses no prior decision.

Why an amendment rather than a new ADR

Decision 8 already named Sandlock a candidate, not the only implementor, and said AASM “may replace or supplement it with a native Linux backend after compatibility, performance, security surface, release cadence and feature requirements are measured.” Reconsideration trigger 2 anticipates exactly this: “a production native-Linux backend replaces the initial third-party substrate and exposes a materially different trust or descendant model.” This amendment is that trigger firing — it exercises a choice the ADR already reserved rather than making a new one, so it belongs here rather than in a competing record.

It is not “no ADR action”: until this amendment nothing recorded which Linux kernel primitives the second implementor uses, why its launcher is shaped the way it is, or how it resolves the one thing Sandlock’s own mechanism could not express (below). Those are durable facts a later implementer needs and Jira comments do not preserve.

The backend: one IsolationBackend, composed, not several

The native backend is one implementor of IsolationBackend, composed from Landlock (filesystem), seccomp-bpf (syscalls) and the existing backend-neutral descendant/evidence machinery already built for Sandlock under AAASM-5709/AAASM-5710 (ambient-descriptor sealing, environment scoping, evidence kinds). It is not a family of narrower single-purpose backends, and it is not a reimplementation of Landlock or seccomp-bpf themselves — both remain kernel mechanisms this backend configures, not code this backend contains.

aa_isolation::backend::IsolationBackend (aa-isolation/src/backend.rs) needs no change to accept it. The trait’s five execution stages plus the AAASM-5711 supervision pair (identity, capabilities, plan, prepare, spawn, wait_for_exit, terminate, evidence) are already backend-neutral by construction: PreparedExecution and ExecutionHandle are opaque tokens precisely so a backend whose unit of confinement is a Linux process, rather than an external supervisor binary, has nowhere it needs to leak that shape into the contract. This was verified against the trait as it stands in this branch, not assumed from the trait’s stated design intent.

Linux primitives: Landlock + seccomp-bpf, and why this pair

Landlock restricts filesystem access (open, execute, and — from the ABI version that supports it — network bind/connect) by attaching a ruleset the calling process cannot widen afterward. seccomp-bpf restricts which syscalls a process may issue at all. Together they cover the two domains decision 6 requires an implementor to answer for — filesystem and process/syscall behavior — using mechanisms that are already in the mainline kernel, require no new kernel module, and (per the commercial constraint) are addressable through permissively-licensed Rust binding crates rather than a redistributed third-party binary. Network-destination restriction is explicitly not attempted by this pair; see “Kernel-level network enforcement” below.

Launcher shape: a small auditable binary, not a pre_exec closure

The backend applies its Landlock ruleset and seccomp filter to itself, synchronously, inside a small, dedicated, auditable Linux launcher binary — the same CLI-invocation shape aa-isolation-sandlock already uses (sandlock-style: the supervisor execs a purpose-built binary that installs the boundary and then execves the target program as its own final act, rather than the supervisor process installing the boundary on itself).

This is a direct instance of decision 5: “Where process-level sandbox initialization requires post-fork/pre-exec work, implementation should prefer a deliberately small and auditable launcher/helper boundary. Complex Landlock/seccomp/namespace setup must not be casually accumulated in an async runtime’s pre_exec callback, where post-fork restrictions make ordinary allocation, locks and library behavior unsafe or difficult to audit.” Landlock ruleset installation and seccomp filter loading are exactly that complex post-fork setup, and aasm run’s supervisor is a Tokio async runtime — the launcher binary is how this backend keeps that setup out of a pre_exec closure rather than an alternative to the ADR’s existing rule.

Seccomp model: allowlist, and why it is not what Sandlock could express

The seccomp filter is a default-deny allowlist: SECCOMP_RET_KILL or SECCOMP_RET_ERRNO for anything not named, SECCOMP_RET_ALLOW for each syscall the resolved EnforcementPlan permits. This matches AASM’s own policy shape — a policy names what is permitted, per decision 3 — where Sandlock’s own mechanism could not follow it: Sandlock’s syscall-filtering surface takes a denied-syscall list, and the complement of an arbitrary permitted set is unbounded, so no finite denied list expresses “permit only these.” That mismatch, not an implementation gap, is why AAASM-5753 (syscall-level enforcement in Sandlock) was deferred rather than shipped as a partial feature — deferring a requirement the mechanism structurally cannot satisfy is the correct call recorded here, not left implicit in a closed ticket.

Amendment — AAASM-5803 (2026-08-20): the installed filter is not exactly the policy allowlist. It is policy names ∪ STARTUP_BASELINE, a fixed set of loader/exec-chain syscalls (execve, clone/clone3/wait4, and a handful of glibc dynamic-linker calls with no name in the policy vocabulary) permitted regardless of what policy stated. A seccomp filter is inherited across execve, not reset by it — the launcher installs the filter on itself before execve-ing the confined program, so the launcher’s own execve and the dynamic loader that runs before the program’s first instruction both execute inside the filter this backend just installed. aa_security::policy::syscall::Syscall’s 15-name vocabulary has no execve in it, so a filter built from policy alone would kill the launcher on every single launch. STARTUP_BASELINE is disjoint from the policy vocabulary by a tested invariant (aa-isolation-native/src/seccomp.rs), so a policy author’s own allowlist selection is never silently widened by it — the practical edge is the reverse: a policy that omits a loader-critical call the vocabulary can name (read, openat, mmap, and ten others) gets its confined program killed during dynamic linking, before its own code runs. This is documented as a SupportLevel::Partial limitation on the syscall capability report, not folded into “the filter enforces the policy allowlist” language anywhere in code or docs.

/proc scoping: how AAASM-5709’s environment grant becomes an enforced boundary

Landlock’s filesystem ruleset will scope /proc read access: another process’s /proc/<pid>/environ (and equivalent per-PID files) is outside the confined process’s allowed read set, while /proc/self and its own descendants remain reachable, because the confined process still needs to read its own process state. Before this, AAASM-5709’s environment-based credential scoping controlled what a child’s own environment contained but nothing stopped that child from reading a sibling or ancestor process’s /proc/<pid>/environ on a shared host. Landlock’s /proc scope closes that gap, which is how this Epic’s filesystem work closes AAASM-5785 and AAASM-5786 — they are closed by this backend’s filesystem primitive, not by separate work targeting those tickets directly.

Kernel/ABI floor: a measurement policy, not a number

This amendment does not assume a minimum kernel version or Landlock ABI number. It records the policy: the floor will be measured against the actual Landlock ABI version the implementation requires for the path-scope granularity the backend claims (Landlock’s ABI versions add capability incrementally — network restriction, for instance, arrives only at ABI v4), and reported truthfully once measured, including degraded or unsupported behavior on a host below that floor — via CapabilitySet/EnforcementEvidence exactly as decision 4 and ADR 0033 §6 already require of every backend. This is the same discipline the existing “kernel floor” (S2) dimension of the AAASM-5713 benchmark methodology already applies elsewhere in this Epic — a measured floor per host, not a guessed one recorded now and corrected later.

Alternatives considered and rejected for this delivery

  • Namespaces (mount/network/PID). No accepted requirement under this Epic’s acceptance criteria demands namespace isolation, and adopting them is a materially larger commitment — namespace setup, teardown and descendant lifecycle all change shape — than the measured problem (filesystem and syscall scoping) requires. Deferred, not ruled out for a later backend generation.
  • cgroups v2 / resource ceilings. Nothing in the AAASM-5713 benchmark data or this Epic’s acceptance criteria requires CPU/memory/PID ceilings yet. Deferred until a measured requirement exists.
  • Kernel-level network enforcement. The existing AASM proxy layer (aa-proxy) remains the network-mediation mechanism for this delivery. Landlock’s own TCP bind/connect restriction (available from ABI v4) is port-based; it cannot express AASM’s IP/host allowlist semantics, so it would not replace the proxy layer even where available — it would only add a second, differently-shaped network control. Not attempted here.

    Cross-reference — AAASM-6163 (2026-09): ADR 0038’s AAASM-6163 amendment adds a contract/truthful-report pair over this same aa-proxy mechanism — a launch’s requirement of brokered egress, checked against what the proxy layer truthfully provides — not a second network control. The mechanism named here is unchanged.

  • A stronger per-action prevention evidence claim than Sandlock already makes. EnforcementEvidence::supports_prevention_claim stays false for this backend’s first version, matching Sandlock’s own honest answer: the kernel delivers a seccomp/Landlock denial to the confined process, and nothing in this delivery gives the supervisor a per-decision record of it. A seccomp SECCOMP_RET_USER_NOTIF decision channel could earn a stronger claim later — the supervisor would then receive a synchronous notification per filtered syscall rather than only configuring a filter the kernel enforces unobserved — but that is explicitly deferred, not committed to here.

Compatibility with Sandlock

Sandlock is not deprecated, removed, or implied to be superseded by this amendment. It remains a supported, available IsolationBackend. Which of the two a deployment uses by default is an evidence-based decision to be made later, under AAASM-5805, once both backends have comparable measured evidence — it is not pre-decided by naming the native backend here.

Licensing

Unlike Sandlock, this backend redistributes no third-party binary — there is no external sandlock-equivalent executable to carry provenance and SBOM data for. Its licensing surface is the Rust dependency crates providing the Landlock and seccomp-bpf bindings, each of which decision 11 already requires to be verified permissively licensed (Apache-2.0/MIT/BSD) and recorded with source/version provenance before it is pinned. This amendment does not pin a specific crate or version; it records that the same pre-pin verification decision 11 already requires applies here, with no third-party-binary case added on top of it.

No new backend-specific vocabulary in user-facing intent

This amendment introduces no exception to the rule decision 3 already applies to “sandlock”: “Landlock” and “seccomp” name mechanisms, not --isolation CLI intent. They appear only where this ADR and future compatibility-matrix, troubleshooting and licensing documentation state implementation facts, never as a value a policy or CLI invocation authors directly.

Consequence for existing text

Where decision 8 described Sandlock as “the first candidate backend” for Linux process isolation, that remains true and is not superseded — this amendment adds a second implementor alongside it, per that same decision’s own anticipation, and changes no other recorded text in this ADR.


Amendment — AAASM-5808 (2026-08-21): --isolation auto selects by capability, not by a fixed default

Scope of this amendment: it names the algorithm --isolation auto uses now that more than one IsolationBackend implementor exists, and records why eligibility is decided by the same plan()/negotiate() machinery a real launch uses rather than a hand-written comparison. It reverses no prior decision.

Update: at the time this amendment was written, exactly two IsolationBackend implementors existed (sandlock, aasm-native), and the candidate list below named only those two. aasm-macos-vm (AAASM-5811) was added afterward as a third IsolationBackend implementor and a third entry in aa-cli/src/commands/run.rs’s CANDIDATES list — the algorithm and its rationale below are unchanged, only the candidate list is longer. See the execution-isolation docs for the current three-entry list.

Why an amendment rather than a new ADR

Decision 8 already reserved that AASM “may replace or supplement [Sandlock] with a native Linux backend,” and the AAASM-5801 amendment exercised that reservation by adding a second IsolationBackend implementor. Neither recorded what --isolation auto does once two implementors exist. Before this amendment, auto resolved to the same fixed default --isolation process already used — whichever id --isolation-backend named, or sandlock otherwise — because, per the pre-AAASM-5801 IsolationIntent::Auto doc comment, “today exactly one class exists.” AAASM-5805’s benchmark data established that Sandlock’s and the native backend’s capability gaps are complementary, not nested: Sandlock covers every domain this ADR’s capability model defines except Syscall; the native backend covers exactly FilesystemRead, FilesystemWrite and Syscall. A fixed default cannot be capability-correct for every policy under that shape — a policy requiring Syscall enforcement cannot be met by the fixed default, and a policy requiring NetworkEgress enforcement cannot be met by the native backend — so this amendment decides what auto now means, rather than leaving two backends behind one hardcoded choice that is wrong for some policies by construction.

The selection algorithm: a fixed, ordered eligibility walk over plan() verdicts

--isolation auto walks a fixed, ordered candidate list — [sandlock, aasm-native, aasm-macos-vm], Sandlock first, then AASM-native, then the macOS VM backend — and selects the first candidate for which backend.plan(probe_spec) returns Ok, where probe_spec is an ExecutionSpec carrying nothing but the launch’s lowered requirements. The walk is lazy: once a candidate is eligible, no later candidate is even discovered or probed. When no candidate is eligible, the launch refuses, naming every candidate considered and why it was rejected — there is no fallback to a default backend or to an unconfined launch, matching decision 4’s existing “refuse before spawn, never degrade to unconfined” rule.

Eligibility is decided by the same narrow_for/negotiate machinery a real launch’s own negotiation uses — not by a hand-written comparison of which domains each backend is known to cover. That is a correctness decision, not a style preference: narrow_for and negotiate read only ExecutionSpec::requirements — never the program, args, identity, working directory or credentials — so a probe spec carrying only the lowered requirement set reaches the identical verdict a real launch’s own spec would against the same backend. A hand-written domain-subset comparison would have to be kept in step with both backends’ actual negotiation behavior by hand and could silently drift from it; reusing the negotiation path the rest of this ADR already requires every backend to answer correctly cannot drift from itself.

What --isolation auto does not do

Naming a backend directly — --isolation-backend, or --isolation process’s existing hardcoded default — is unaffected and unchanged by this amendment: an operator who names a backend always gets that backend, or its own refusal, never automatic selection’s substitute. Automatic selection also does not run when no policy lowered to any requirement at all; that remains the existing NoRequirementsLowered refusal this ADR already records, not a new refusal this amendment introduces.

Consequence for existing text

Where the IsolationIntent::Auto documentation previously said this intent “resolves to Process” because “today exactly one class exists,” that statement described the period before AAASM-5801 added a second backend; this amendment supersedes it with the algorithm above. No other recorded text in this ADR changes.


Amendment — AAASM-6167 (2026-09-25): selection generalizes from backend ids to a RuntimeRequirements property set

Scope of this amendment: it moves the AAASM-5808 eligibility walk from a CLI-specific, name-branching implementation into a backend-neutral mechanism aa-isolation owns, and adds an independent evidence-quality floor on top of it. It reverses no prior decision: the walk’s algorithm — decide eligibility per candidate via plan()/negotiate(), select the first eligible candidate in a fixed order, refuse naming every candidate and why — is unchanged.

Why an amendment rather than a new ADR

The AAASM-5808 amendment recorded the selection algorithm while it lived in aa-cli/src/commands/run.rs::auto_select, branching on each backend’s crate-level BACKEND_ID to construct a concrete SelectedBackend. That was adequate while aasm auto was the only caller and the only requirement vocabulary was “the launch’s lowered policy requirements.” Runtime 2.0 (Epic AAASM-6159) needs the same decision reachable from a policy or operator statement of runtime properties — not backend names — and needs new backend candidates (AAASM-6168/6169/6170’s gVisor, Firecracker and Apple Containerization spikes) addable without a new match arm in aa-cli. Both are the same generalization this ADR already anticipated: decision 8 and the commercial/performance constraints above already frame backends as pluggable and their properties as measured, not named.

What this amendment actually ships, and what remains a follow-up

aa-isolation::planner::select is a new, independent implementation of the walk this ADR already names, living in the crate that owns negotiate() rather than in aa-cli: it takes a aa_isolation::RuntimeRequirements (a versioned, backend-neutral property set — confinement domains, resource ceilings, an evidence/attestation floor, an allowed PlatformBoundary set) and a caller-built, declared-order list of aa_isolation::Candidate { identity, capabilities } — data, not dyn IsolationBackend objects, since aa-isolation cannot depend on any backend crate. Eligibility for each candidate is decided by exactly one call into negotiate() — the same “single decision point” this ADR already establishes — so no per-domain refusal rule changes, proven by a mutation test and by a positive control against aa-isolation-macos-vm’s own real capability-reporting logic (not a MockBackend).

aa-cli/src/commands/run.rs::auto_select now delegates its eligibility decision to aa-isolation::planner::evaluate_candidate (AAASM-6167’s own follow-through, landed in the same ticket this amendment records). It calls evaluate_candidate, not select: select forces eager discovery of every candidate up front, which would break the lazy discovery aa-cli/tests/run_isolation.rs and aa-isolation/tests/negotiation.rs already pin (the second candidate must never be probed when the first is eligible), and select’s generic PlanRefusal::to_string() detail would replace the CLI’s own, more actionable describe_refusal wording. The loop, the availability pre-check and the ConsideredBackend construction are otherwise unchanged — only the eligibility oracle and the capabilities fed to it moved. The construction site still names concrete backend types, since Rust has no plugin loader; see “Consequence for existing text” below for why that construction site must narrow those capabilities before handing them to evaluate_candidate.

The evidence-minimum bar: a second, independent gate

negotiate()’s prevention check reads exactly three axes on a CapabilityReport — Mediation, DecisionTiming, Synchrony — established by decision 4. It has never read FailurePosture or SupportLevel, and this amendment does not add either read to it. Instead, RuntimeRequirements::EvidenceMinimum states an independent floor on those two axes, and aa-isolation::planner checks it directly against a candidate’s BackendCapabilities after negotiate() already accepted the candidate — never instead of, and never in a way that can turn an unmet negotiate() requirement into an accepted one. A candidate that is Enforce/Pre/Sync on a domain (and therefore can_prevent()s it) can still be FailurePosture::FailOpenSilent, or SupportLevel::Partial with stated gaps, on that same domain; this amendment’s floor is how a caller excludes such a candidate anyway. An unreported domain — report_for returning None — fails any stated evidence minimum for the same “unknown is not interpreted as supported” reason a missing capability report already refuses a negotiate() prevention requirement.

Tie-break remains declared order, not a performance score

The ticket’s design language (“performance among security-equivalent candidates”) does not change the mechanism here: this workspace has no measured performance data for the new candidate backends the AAASM-6168/6169/6170 spikes describe — those PRs are unmerged capability-and-tradeoff writeups, not shipped IsolationBackend implementors with real benchmark numbers — so encoding a numeric ranking now would be a policy input this ADR cannot yet stand behind. aa-isolation::planner::select therefore keeps exactly the AAASM-5808 tie-break: the first candidate in the caller’s declared order that is eligible wins. aa-cli’s own CANDIDATES list, unchanged by this amendment, produces the identical selection backend.plan() would for every requirement set expressible today — but only because the construction site now supplies evaluate_candidate the same narrowed capabilities plan() computes internally via each backend’s own capability::narrow_for (see “Consequence for existing text” below). Feeding evaluate_candidate a candidate’s raw, unnarrowed capabilities does not reach this parity — a domain narrow_for would have made ineligible (e.g. a Resource ceiling lower::resource cannot express) can still look eligible on the raw report, which is exactly the regression narrowing at the construction site exists to prevent.

Named runtime classes remain out of scope

The ticket also raised whether a named, user-facing “runtime class” (a documented bundle of properties) should exist alongside RuntimeRequirements. None is introduced by this amendment. RuntimeRequirements is the property mechanism only; a class built from it, were one added later, must satisfy this ADR’s existing “no new backend-specific vocabulary in user-facing intent” constraint and must never document a stronger guarantee than the explicit properties it bundles — deferred entirely rather than shipped provisionally.

Consequence for existing text

The AAASM-5808 amendment’s description of the algorithm as living in aa-cli/src/commands/run.rs::auto_select still describes where the eligibility decision is made today. This amendment does not record the two implementations as an independent implementation of the identical algorithm: auto_select’s own backend.plan() narrows a candidate’s capabilities via each backend’s capability::narrow_for before calling negotiate(), while aa-isolation::planner::evaluate_candidate negotiates whatever capabilities the caller’s Candidate carries, narrowed or not. The two paths agree only when the construction site performs that narrowing itself before building the Candidate — they were not, and are not by construction, the “identical algorithm” independently reached. auto_select now delegates its own eligibility decision to evaluate_candidate directly (never to select, which would force eager discovery of every candidate and replace the CLI’s own describe_refusal wording with PlanRefusal::to_string()), feeding it capabilities narrowed for the probe spec at the construction site via a new SelectedBackend::capabilities_for. That is what makes auto_select’s selection and backend.plan()’s selection agree for every requirement set expressible today — not an incidental property of the two algorithms being written the same way twice. A debug_assert_eq! inside auto_select’s loop checks this per candidate in debug builds, but only for a candidate that passes the availability pre-check first — on a host where every candidate reports Unavailable (true of every macOS development host today, since sandlock and the native backend’s Linux-only mechanisms and the still-unavailable macOS VM backend are all unavailable there) the assertion never executes for any candidate, so it pins this claim only on a provisioned Linux CI runner, not on every host that builds this code. No other recorded text in this ADR changes.


Amendment — AAASM-6166 (2026-09-27): execution receipts bind policy, spec, backend and evidence into one durable, sealed artifact

Scope of this amendment: it adds a versioned, local, sealed record of one run’s isolation posture — a durable projection of exactly the facts §10 already requires a report to carry, written once at the end of a confined launch. It reverses no prior decision, adds no new claim vocabulary, and changes no IsolationReport/EnforcementEvidence type. Parent Epic AAASM-6159 (“Agent Execution Runtime 2.0”).

Why this belongs here rather than in a new ADR

§10 of this ADR (“Evidence is first-class output of execution”) already states the property this amendment implements durably: a run’s evidence must be joinable back to the plan it executed, and IsolationReport/EnforcementEvidence are the in-memory types that carry it. report.rs’s own module documentation already cites this ADR as its authority. An execution receipt is that same joined fact set, written to disk once, at the one place — the end of run_confined — where the plan, the achieved boundary and the runtime evidence are all in scope simultaneously. Nothing about the receipt’s content is new; what is new is that it persists past process exit, and persisting it durably is what “signed execution receipts” in the parent Epic’s own Goal line asks for.

The seal is a content digest, never a signature — and this is final for this ticket

The Epic’s Goal line says “cryptographically binds… signed execution receipts.” This amendment implements the seal as sha256 over a canonical JSON rendering of the receipt’s body — a content digest, not a signature and not a MAC — and the word “signed” appears nowhere in the implementation’s types, CLI output, or documentation except in a sentence stating what the seal is not. This mirrors aa_core::integration::store’s own receipts, whose module documentation already states the identical position for a different artifact: “Integrity is corruption detection, not tamper prevention… not a MAC.”

The reasoning is structural, not a matter of preference: a real signature needs a private key the writer holds and a verifier that does not share the writer’s host UID — otherwise an attacker capable of rewriting the receipt is, by construction, also capable of reading whatever key would have signed it, and a signature would add nothing an unkeyed digest does not already provide. aasm run writes its own receipts on the same host, under the same UID, that would hold any such key. The only place a signature buys something real is a SaaS ingestion path — a separate host holding a key the local writer never sees — which is explicitly out of scope for this local-first design. An HMAC was considered for the same reason and rejected on the same ground: the key would sit next to the receipt it protects.

ReceiptSealKind is #[non_exhaustive] with exactly one variant (ContentDigestSha256) so that a future kind — were a SaaS verification path ever built — is additive to the schema rather than a redesign, without this ticket deciding what that kind would be.

What a holding seal establishes, and what it does not

A holding seal establishes that the receipt’s body has not changed since it was written, and that the canonical form the digest was computed over is the one this build reads. It does not establish origin (see above), the verified identity of the agent the run is attributed to (asserted_identity is named that way deliberately — aa_isolation::spec::IdentityRef already documents that a caller-supplied identity reference is asserted, never authenticated, by anything in this crate, and nothing in this amendment closes that residual), or that any property the receipt records as unmeasured actually held.

One boundary is worth stating precisely rather than leaving implicit: the seal digests the receipt’s body only. The envelope’s schema identifier and the seal’s own metadata (sealed_by, sealed_at_unix_secs, canonical_form) sit outside the digest and are freely mutable without producing a mismatch. This is benign under the implementation shipped here — mutating the schema string produces an independent UnknownSchema validation defect, and only one canonical form exists today — but it is a real property of the design, not a theoretical one, and aasm receipt verify closes the practical gap by always running both the seal check and the validation rules below, reporting failure if either one fails. A pinned test (mutating_the_envelope_schema_holds_the_seal_but_is_still_a_defect) asserts this boundary as a fact rather than leaving it as a claim in this document alone.

Missing evidence cannot validate as a stronger state — a checked predicate, not a convention

The parent Epic’s Design Constraints already state the target property: “Receipt signature proves integrity/origin, not properties that were never measured.” This amendment implements it as eight checked rules (R0–R7, aa-cli/src/commands/execution_receipt/validate.rs) run at construction and at verification time — the latter is what makes the property hold against a hand-edited receipt, not merely against aasm run’s own honest construction path. In summary: every one of the ten CapabilityDomains must be present (R0); a claim that asserts coverage needs at least runtime evidence behind it, never setup-only (R1); the one prevention term additionally needs a Decision-grade record, and the receipt’s own prevention_supported flag must agree with that grade rather than being asserted independently (R2/R2b); a domain carrying a degraded condition cannot also carry a prevention claim (R3); no domain may claim coverage when no execution backend ran at all (R4); a state that itself asserts nothing (unmeasured/unsupported) cannot carry a coverage-asserting claim (R5); every withheld field must be recorded, never silently dropped (R6); and the recorded timeline must not be inverted (R7). aasm receipt verify reports the seal and these rules as two independent results, never collapsed into one pass/fail — a freshly, correctly resealed receipt that overclaims still fails on the rules alone, which is the case the parent Epic’s Design Constraints are actually worried about.

No new ClaimTerm, EvidenceKind, AttestationBasis, or CapabilityDomain variant was added to implement this — the existing ADR 0033 §6 vocabulary and report.rs’s EvidenceBasis/ControlState ladders already say everything R0–R7 need to check. scripts/check_claim_vocabulary.py passes on the new module and documentation unchanged.

One narrow addition was necessary: host::FactBasis, for a host or build fact (architecture, kernel release) that carries no protection claim at all — kernel release is not a control’s state, so grading it with aa_core::attestation::AttestationBasis (which exists specifically to grade protection components toward claim_ceiling) would misfit a safety-critical vocabulary onto a fact that has nothing to do with enforcement. FactBasis’s unmeasured arm reuses aa_isolation::UnmeasuredReason’s token vocabulary rather than duplicating it a third time.

Redaction is structural, and the credential screen is the second line

No field of a receipt’s body holds an argument value, an environment variable’s value, a lease’s basis reason, or a scope selector — those travel as digests (argv_digest, working_dir_digest, a lease’s own redaction-safe projection digest) and counts (arg_count, scope_selector_count). This is the primary guarantee, and it holds regardless of the credential screen below: even if contains_credential_material matched nothing, the value was never in the digest input or the stored field to begin with. The few fields that do carry operator-visible free text (a policy source path, an Inconclusive detail sentence, a degraded-condition detail) are screened through aa_core::integration::fingerprint::contains_credential_material and withheld — recorded as withheld in withheld_fields, never partially redacted, never silently dropped — on a match. That screen is a floor, not a proof, exactly as aa-core’s own documentation for it already states; this amendment does not claim otherwise.

Why aa-isolation gains no serde feature, and the resulting residual

aa-cli/Cargo.toml keeps aa-isolation dependency free of the serde feature. aa-isolation’s own lib.rs documents enabling that feature as “a decision to expose a wire contract” the crate is not ready to make while several sibling tickets are still reshaping these types, and ExecutionSpec::working_dir is a PathBuf whose default Serialize impl fails on non-UTF-8 content — unlike the rest of a launch’s argv, which is already checked for UTF-8 at the CLI boundary, nothing upstream of this amendment checks the working directory the same way. The receipt module therefore projects each real aa-isolation type into a private, redaction-safe mirror struct (SpecProjection, LeaseProjection) before serializing anything, computing the working-directory digest from the path’s raw bytes rather than through Serialize at all.

The residual this does not close, stated plainly rather than hidden behind the mirror-struct technique: a field added to ExecutionSpec or CapabilityLease later is not part of a receipt’s digest until someone adds it to the corresponding projection here. The mirror struct’s exhaustive destructure (the same BoundLaunchFields/E0027 technique run.rs::IsolationPlan::base_spec already uses) only turns a field being dropped from this module’s own local extraction into a compile error — it cannot, and does not claim to, catch a field that was never extracted from the upstream type in the first place.

Measured versus asserted, by platform — naming the difference precisely

A receipt’s host facts distinguish three bases: Measured (probed on this specific run — e.g. /proc/sys/kernel/osrelease on a Linux-native or sandlock-confined launch), Asserted (a build-time constant, or a backend’s own documented claim about a component it does not probe — e.g. aa-isolation-macos-vm’s guest architecture, stated in that backend’s own source rather than measured at runtime), and Unmeasured (nothing established a value). The field carrying the compile-time architecture constant is named build_target_arch, never host_arch — conflating the two is exactly the class of overclaim the AAASM-5528 incident this campaign exists to prevent already recorded, applied here to a new artifact rather than relitigated. A macOS-hosted VM launch’s kernel-release fact is Unmeasured, honestly, because the guest kernel — not the host’s — is what actually executed the confined process.

What this amendment does not decide

  • No SaaS ingestion or verification service. Verification is local-only, via aasm receipt verify and the equivalent library call.
  • No signature, key, PKI, or MAC. See above; ReceiptSealKind leaves room for a future kind without deciding what it is.
  • No replay or forensic reconstruction of a run from its receipt. Tracked separately under AAASM-6172.
  • runtime_image is None on every backend today. No backend computes a digest of its guest/runtime image; this amendment does not hash one on every launch and does not touch IsolationBackend. The field exists in the schema so a future backend that does compute one has somewhere to put it.
  • workspace is None today. aa-workspace-tx has zero consumers (grep -rln aa-workspace-tx --include=Cargo.toml .) — this amendment types the field against that crate’s existing vocabulary so wiring it later is a projection, not a redesign, but builds no workspace-diffing subsystem.
  • No receipt for a launch refused before it starts. authority_gate/egress_gate/ negotiation refusals return before run_confined and are already reported synchronously by isolation_machine_block — there is nothing an execution receipt could truthfully describe about a program that never started inside a boundary. What happened to a run that never started is AAASM-6172’s question, not this amendment’s.
  • No retention or garbage collection. One 0600 file is written per confined run; growth is accepted and stated, not managed, for this ticket.
  • No change to aa-isolation, aa-proxy, aa-core, or ADR 0033. ADR 0033 remains AAASM-5654’s territory, still To Do at the time of this amendment.
  • No new dependency.

Context

Agent Assembly already owns the governance semantics above execution: agent identity and lineage, organization/team policy, approvals, budgets, MCP governance, sensitive-data controls, developer-tool integration, transport mediation, audit and evidence-backed protection state. aasm run already performs a managed launch for supported AI developer tools: it resolves policy, registers an identity, applies managed settings and proxy configuration, builds the child environment, starts the child and supervises lifecycle.

What it does not currently establish is a general synchronous OS-enforced boundary around the whole native process tree. A child that is not mediated by an SDK or proxy can still exercise capabilities exposed by its host identity unless another host mechanism constrains them. Linux eBPF improves evidence and detection, but ADR 0033 records that its current file/TLS/exec probes are observational and its opt-in syscall guard terminates asynchronously. The source says so in-code: “bpf_send_signal is asynchronous; the SIGKILL lands at the next signal-check point, so this syscall still runs once before the task dies” (aa-ebpf-probes/src/syscall_guard.rs:189-192). Under ADR 0033 §6 that mechanism reaches Detected, not Denied before execution, and the two are not interchangeable.

The security requirement is therefore not “put the agent in another process”. A parent process is not a security boundary by itself. The requirement is that a trusted supervisor establishes kernel/runtime-enforced restrictions before executing the untrusted child, and that those restrictions cover descendants according to explicit inheritance semantics.

The product requirement is also not “build another sandbox”. Sandlock, seccomp/Landlock, gVisor, Firecracker and future platform mechanisms solve different portions of execution isolation. Re-implementing all of them would duplicate mature low-level work while still leaving Agent Assembly’s higher-level questions unanswered: which agent is acting, under which policy, which capabilities are required, which controls were actually applied, and what evidence justifies the UI or audit claim.

The commercial constraint

Agent Assembly is an open-core product with commercial extensions. A backend strategy must therefore preserve a clean product boundary between AASM-owned governance semantics and third-party isolation implementations. Built-in or redistributed backends should prefer permissive licenses (for example Apache-2.0/MIT/BSD) and carry explicit third-party notices, provenance and SBOM data. A backend with materially different copyleft or service terms requires a separate product/legal decision; it must not enter a proprietary distribution accidentally through an implementation shortcut.

The performance and compatibility constraint

No isolation mechanism is universally best. Coding agents are unusually syscall- and I/O-heavy: they traverse repositories, spawn compilers and test runners, use Git, package managers and language toolchains, open many small files and maintain network sessions. A stronger userspace-kernel or microVM boundary may impose a different compatibility or performance cost than a process-level Landlock/seccomp boundary. Conversely, a process sandbox shares more host-kernel attack surface than a userspace kernel or VM.

Consequently, performance and compatibility are measured backend properties, not architecture claims. The product must not encode one mechanism as “strong” merely by name, nor choose a universal backend without workload evidence.


Threat model

The default threat model for this ADR is:

  1. The agent and all code it causes to execute are untrusted. The agent may adaptively enumerate files, environment, network routes, metadata endpoints, IPC, sockets, credentials, process state and alternate execution paths.
  2. The host operator and AASM supervisor are trusted. Protecting a machine from its own root/administrator account is not provided merely by this local execution model.
  3. The process tree is the minimum native execution security unit. Threads share an address space and other process state and are not accepted as mutually hostile security domains.
  4. A descendant cannot gain broader authority by ordinary fork/exec. Child and sub-agent processes remain at the same or narrower effective capability scope unless an explicit delegated-capability mechanism is introduced and evidenced.
  5. The absence of an event is not proof that the agent did nothing. An unsupported, bypassed or uninstrumented path is reported according to ADR 0033/0030 evidence semantics, never inferred clean.

This ADR does not claim protection against a trusted host operator deliberately removing AASM, replacing its binaries, changing the kernel, modifying a policy they are authorized to modify, or otherwise exercising administrator ownership of the machine.


Decision

1. aasm run is the canonical managed-execution front door

Agent Assembly will evolve the existing aasm run surface rather than create a separate product-shaped aasm sandbox run command.

Existing developer-tool forms remain backward compatible:

aasm run claude [args...]
aasm run codex [args...]
aasm run windsurf [args...]

A generic execution target will allow arbitrary agents/commands to enter the same identity, policy, lifecycle, isolation and evidence flow. The exact CLI grammar is an implementation/API review item; the intended shape is equivalent to:

aasm run exec -- python ai_agent_main.py

Isolation is a property of the run, not a separate lifecycle.

2. AASM owns the execution contract; backends own mechanism-specific realization

The durable contract consists of four conceptual objects (names may be refined without changing their semantics):

  • ExecutionSpec — the identity-bound, policy-derived statement of the process to run and the capabilities/resources it requires or forbids.
  • CapabilitySet — a backend’s machine-readable statement of what it can enforce, observe or cannot represent on this host/version.
  • EnforcementPlan — the resolved lowering from ExecutionSpec to a selected backend, including unsupported/degraded requirements before launch.
  • EnforcementEvidence — the post-prepare/runtime facts that justify which ADR 0033 §6 claim term the product may attach to each control for this run.

A concrete backend implements an internal contract equivalent to:

#![allow(unused)]
fn main() {
trait IsolationBackend {
    fn capabilities(&self) -> CapabilitySet;
    fn plan(&self, spec: &ExecutionSpec) -> Result<EnforcementPlan>;
    fn prepare(&self, plan: EnforcementPlan) -> Result<PreparedExecution>;
    fn spawn(&self, prepared: PreparedExecution) -> Result<ExecutionHandle>;
    fn evidence(&self, handle: &ExecutionHandle) -> EnforcementEvidence;
}
}

The trait shape above is illustrative; the semantic separation is normative.

A backend may use Sandlock, native Landlock/seccomp, gVisor, a microVM, an enterprise host mechanism or a future platform adapter. None of those names appear in the canonical AASM policy vocabulary merely because an implementation uses them.

3. Isolation class is not backend identity

User/product policy describes the required isolation properties, not the preferred vendor or mechanism. AASM may expose stable requirement classes such as process, userspace-kernel, microVM/hardware-backed or automatic selection, but a class is not a promise that one named backend will always implement it.

Concrete backend selection is an execution-plan fact. Advanced diagnostics may expose or pin a backend for reproducibility, but ordinary policy should survive a backend change.

Backend replaceability is an implementation property of this design, not the product value. What the product offers is portable policy semantics — one AASM policy that keeps its meaning across execution substrates — together with the evidence that records which controls that policy actually achieved on a given run. The backend interface exists to preserve those two things. Material derived from this ADR must not present pluggability itself as the benefit.

4. Capability negotiation happens before the untrusted process starts

The execution planner resolves policy requirements against the selected backend’s actual host capabilities before launch.

For every required control, planning resolves to exactly one outcome. This ADR does not invent a parallel vocabulary for those outcomes: each maps onto a term already defined by ADR 0033 §6, which stays authoritative for the terms themselves.

Planning outcomeADR 0033 §6 term the run may then claim
The backend has a mechanism that refuses the action before it takes effectDenied before execution
The backend can report the action but cannot refuse it before effectObserved / Detected
No applicable mechanism exists on this host, backend or backend versionUnsupported
A planned control is configured but unavailable, and posture permits proceedingDegraded, carrying both the planned and the achieved level
Nothing inspected this action or payloadUnmeasured

The following are normative:

  • A required pre-effect control that the selected backend can only observe must not be promoted to Denied before execution.
  • The default for an unmet required capability is refusal before launch.
  • Degraded is reachable only where policy or operator posture explicitly permits it, and it must carry both levels into E6 evidence and audit rather than presenting the run as equivalently protected.
  • A backend that is implemented but not validated for production use is Experimental; a control that is decided but not implemented is Planned and carries no capability claim. Neither may be reported as an achieved control.

5. The trusted supervisor stays outside the confined process tree

The AASM control/runtime process must not share the hostile process’s security boundary in a way that grants the child access to supervisor memory, privileged descriptors or credentials simply because both participate in one launch.

Where process-level sandbox initialization requires post-fork/pre-exec work, implementation should prefer a deliberately small and auditable launcher/helper boundary. Complex Landlock/seccomp/namespace setup must not be casually accumulated in an async runtime’s pre_exec callback, where post-fork restrictions make ordinary allocation, locks and library behavior unsafe or difficult to audit.

The target process and descendants execute inside the prepared boundary; the supervisor coordinates lifecycle, evidence and policy outside it.

6. Descendant confinement is part of correctness

An agent that can escape merely by spawning a child has no meaningful process boundary. Therefore an execution backend is not considered capable of a process/filesystem/network restriction unless its semantics cover the descendant processes relevant to that restriction, or the limitation is explicitly surfaced in CapabilitySet and evidence.

Sub-agent identity may become narrower or more specific at the governance layer, but ordinary process creation must not widen OS capabilities.

7. aa-sandbox remains the tool sandbox

aa-sandbox continues to execute WASM-marked tools using Wasmtime/WASI controls such as preopened filesystem paths, instruction fuel and memory limits. It is not renamed into or silently reused as the whole-agent native process boundary.

A native agent may call a WASM tool inside aa-sandbox; that is composition of two boundaries, not evidence that either one replaces the other.

8. Linux process isolation is the first implementation target; Sandlock is the first candidate backend

The first production experiment will target Linux process confinement using a permissively licensed existing substrate rather than immediately reimplementing every Landlock/seccomp edge case. Sandlock is the initial candidate because its process-based model maps closely to the required trusted-supervisor/restricted-child shape and provides useful Linux isolation primitives.

This is intentionally a candidate implementation decision, not policy architecture. AASM may replace or supplement it with a native Linux backend after compatibility, performance, security surface, release cadence and feature requirements are measured.

No Linux backend implies macOS or Windows support. Platform-specific mechanisms remain separate E4 decisions and capability reports.

9. Ambient credentials are capabilities, not convenient environment

The execution plan must treat inherited credentials, sensitive environment variables, open file descriptors, sockets and similar ambient authority as part of E5. The target state is least-authority inheritance and brokered/delegated credentials where practical.

This ADR does not claim that credential brokering is complete. Existing credential and secret-hardening work remains authoritative for its implementation. Until then, evidence must distinguish credentials intentionally delegated to the child from ambient authority that could not yet be removed.

Cross-reference — AAASM-6164 (2026-09): ADR 0038’s AAASM-6164 amendment adds a contract/truthful-report pair over aa-proxy’s existing egress credential-injection mechanism (aa-proxy/src/credentials.rs, AAASM-3578/AAASM-5926) — a launch’s requirement of brokered credentials, checked against what that mechanism truthfully covers, and a default (no-escape-hatch) withholding of a brokered provider credential’s env name from the child at aasm run when the mechanism actually covers it. The injection mechanism named here is unchanged; this is the “credentials intentionally delegated … from ambient authority that could not yet be removed” distinction this paragraph already anticipated, applied to the one case a real brokerage mechanism now closes.

Cross-reference — AAASM-6171 (2026-09): ADR 0038’s AAASM-6171 amendment adds a typed host-capability broker (Xcode/Simulator native operations) as a supervisor-side mediation boundary, not a new backend implementation of this ADR’s IsolationBackend trait. It binds to this ADR’s own CapabilityDomain::ProcessCreation (plus FilesystemRead/FilesystemWrite/ Credential) rather than adding a domain, and states plainly — because it is true, not because this ticket introduces it — that no OS confinement backend exists on macOS today: the broker is the only mediation between a governed launch and a real xcodebuild/simctl invocation on that platform.

10. Evidence is first-class output of execution

A successful process spawn is not evidence that every desired control was enforced. aasm run --dry-run and runtime status will evolve to expose at least:

  • requested isolation/capabilities;
  • selected backend and backend version/provenance;
  • planned controls, each with the ADR 0033 §6 term it is expected to reach;
  • requirements resolved as Unsupported or Degraded, the latter naming the planned level alongside the achieved one;
  • evidence sources available after prepare/start;
  • the resulting launch posture: ready, refused or explicitly degraded.

The exact UI may evolve, but requested-versus-achieved separation is normative.

11. Backend provenance and license compatibility are release inputs

A distributed backend must have recorded license, source/version provenance, modification status and SBOM/third-party-notice handling. Permissive dependencies are preferred for built-in OSS/commercial distribution paths. A backend whose license or hosted-service terms materially change the commercial obligations cannot be enabled as an equivalent built-in implementation without review.

This requirement prevents implementation substitution from silently changing the legal or distribution boundary of the product.

Scope of this section. It records an engineering and release-process requirement — which facts a backend must carry, and which changes require review before it ships. It is not legal advice and does not interpret any licence. Whether a particular licence or hosted-service term is acceptable for a given distribution channel is a decision for the product and legal owners; this ADR records that the question must be asked, not what the answer is.


Initial execution flow

flowchart TB
    CLI["aasm run"] --> RP["Run planner"]
    RP --> ID["Agent identity / lineage"]
    RP --> POL["Effective AASM policy"]
    ID --> SPEC["ExecutionSpec"]
    POL --> SPEC
    SPEC --> SEL["Backend selection + capability negotiation"]
    SEL -->|requirements satisfied| PLAN["EnforcementPlan"]
    SEL -->|required capability missing| REFUSE["Refuse or explicit degraded posture"]
    PLAN --> PREP["Prepare execution boundary"]
    PREP --> SUP["Trusted AASM supervisor"]
    PREP --> AGENT["Untrusted agent process tree"]
    AGENT --> CHILD["Children / sub-agents: same or narrower OS authority"]
    PREP --> EV["EnforcementEvidence"]
    AGENT --> EV
    EV --> E6["E6 audit / protection-state pipeline"]

Transport mediation, MCP decisions, DLP and WASM tool sandboxing remain composable controls around this execution flow. The diagram does not turn them into one ordered fallback pipeline; ADR 0033’s independent-element rule still applies.


Backend capability examples

The capability vocabulary must describe semantics rather than product names. Candidate axes include:

CapabilityRequired distinction
Filesystemread/write/create/delete scope; pre-effect deny vs observation
Networkdestination/port/protocol scope; DNS behavior; pre-egress deny vs observation
Syscallsynchronous errno/kill/trap/observe semantics
Process treefork/exec inheritance, maximum descendants, signal/control semantics
ResourcesCPU, memory, PIDs, wall time, file size / descriptor limits
IPCUnix sockets, shared memory, namespaces and inherited descriptors
Credentialsremoved, delegated, brokered, visible-to-child or unmeasured
Platform boundaryshared host kernel, userspace kernel, guest kernel/microVM
Evidencewhich ADR 0033 §6 term the control reaches, and whether that rests on configuration, an exercised code path or independent verification

A single boolean sandbox=true or supported=true is insufficient.


Alternatives considered

Reimplement native Landlock/seccomp/namespaces immediately

Rejected for the first delivery. It would make low-level correctness and kernel corner cases the critical path before AASM has validated its execution contract, CLI and policy semantics. A native backend remains a valid later optimization or independence strategy.

Hard-wire Sandlock into aasm run

Rejected. It would make an upstream implementation part of the product model, couple policy vocabulary to one Linux mechanism, complicate macOS/Windows/server evolution and increase exposure to upstream compatibility, release or license changes.

Treat backend pluggability itself as the product moat

Rejected as positioning. Replaceability is an architectural advantage, but customers buy the governance outcome: one policy, controlled capabilities and evidence across execution environments. The backend interface exists to preserve that product contract.

Treat current eBPF enforcement as equivalent to a process sandbox

Rejected. ADR 0033 §6 places the eBPF syscall guard at Detected plus asynchronous termination, and explicitly not at Denied before execution; the guard’s own source records that the offending syscall runs once before the SIGKILL lands (aa-ebpf-probes/src/syscall_guard.rs:189-192). A pre-effect confinement requirement is therefore not satisfied by enabling the syscall guard, and evidence from one term of the §6 vocabulary cannot be relabelled as a stronger one.

Create aasm sandbox run

Rejected. It would split identity/policy/lifecycle semantics into competing launch paths and make isolation appear optional or separate from governed execution. aasm run is already the managed-launch surface and should be extended.

Isolate an agent thread inside a trusted process

Rejected as a hostile-code boundary. Threads share process address space and substantial state; the security unit here is a separately confined process tree.

Require a microVM for every launch

Rejected as the universal default. MicroVMs can offer a stronger kernel boundary for some workloads but impose deployment, startup, filesystem/toolchain and compatibility costs that are inappropriate to assume for all local developer-agent runs. They remain a future backend class for risk profiles that justify them.


Consequences

Positive

  • AASM owns durable agent-execution semantics without reimplementing every isolation primitive.
  • Backends can evolve or be replaced without changing organization/agent policy language.
  • Local process isolation, server userspace-kernel isolation and future microVM execution can share one evidence and governance model.
  • A failed or partial backend cannot truthfully appear equivalent to successful enforcement.
  • The open-source/commercial boundary can be managed independently from a particular permissively licensed substrate.

Costs

  • Capability negotiation is more complex than a boolean sandbox flag.
  • Every backend needs conformance, adversarial, compatibility and performance evidence.
  • AASM must maintain backend-version and platform capability matrices.
  • Cross-platform support remains uneven until platform-specific backends are actually implemented.
  • Process isolation adds new packaging and third-party supply-chain responsibilities.

Validation requirements

Before a backend can support a prevention claim:

  • Each claimed capability has a precise decision-timing definition.
  • Required-capability failure refuses launch or enters an explicitly permitted weaker posture.
  • Descendant processes are covered or the limitation is surfaced before launch.
  • Adversarial tests attempt direct filesystem, network, process, IPC and credential bypass paths.
  • Negative controls prove the harness fails when an enforcement mechanism is removed/bypassed.
  • Representative coding-agent performance/compatibility tests are recorded.
  • Backend provenance, version, license and SBOM/notice requirements are recorded.
  • E6 output never promotes Observed/Detected into Denied before execution, and never reports backend availability or configuration as an achieved control.

Reconsideration triggers

Re-open this ADR when any of the following changes the durable contract:

  1. A backend requires policy semantics that cannot be represented by ExecutionSpec or the capability model without naming the backend.
  2. A production native-Linux backend replaces the initial third-party substrate and exposes a materially different trust or descendant model.
  3. gVisor/userspace-kernel or microVM execution becomes a default rather than an optional risk-tier backend.
  4. macOS Endpoint Security/App Sandbox or a Windows host mechanism becomes a production whole-agent execution boundary.
  5. Root/admin-resistant managed-device protection becomes a product requirement.
  6. Credential brokering changes the meaning of ambient authority available to the child.
  7. Licensing or distribution constraints make a currently supported backend incompatible with an OSS or commercial channel.

Implementation ownership

Implementation is tracked by Epic AAASM-5702. This ADR defines the durable product/system decision; it does not prescribe branch/PR scheduling or CI procedure. Those remain contributor workflow concerns under the repository’s existing process documentation.


Last updated: 2026-10-02 by Chisanan232