Extension Sandboxing — Implementation Design
🦾 Written with LLM assistance [claude-opus-5] 💪 Reviewed by a human before submission
Paired with ADR: Extension Sandboxing.
Nothing here is built before the stdin/stdout transport proof of concept that the ADR makes its phase-0 gate has landed and been validated. The ADR's section 6 sets the delivery order; this design covers phases 1 to 3.
The plan protocol
An extension never performs an effect. It receives a request and returns a plan: a declarative description of what it wants to happen. The host validates the plan and executes it.
Green = untrusted extension code. Orange = the only component that touches the outside world, and it is host code.
Plan shape
A plan is a list of intents. The host rejects any intent the extension lacks capability for, and rejects the whole plan rather than applying it partially.
| Intent | Fields | Used by |
|---|---|---|
write |
path, mode, content |
target extensions |
delete |
path |
target uninstall |
rename |
from, to |
target migration between layouts |
fetch |
url, method (GET), headers, dest |
source extensions |
clone |
repo, ref, depth, dest |
git source extensions |
fetch and clone are executed by the host's own HTTP and go-git clients.
This is a security control and a feature: proxy configuration, credential
handling, TLS policy, retry, and cache behaviour are implemented once in the
host rather than reimplemented per source extension.
Remote access policy
A fetch or clone intent names a destination; the host decides whether to
reach it. The policy is host-side and identical in both tiers.
- Schemes.
httpsforfetch;httpsorsshforclone. Plainhttpis rejected rather than silently upgraded, so a downgrade is an error the author sees while authoring. - Methods.
fetchcarriesGETand nothing else. The plan's atomicity guarantee covers the local tree, and a remote mutation cannot be rolled back when a later intent fails, nor made safe to retry. Restricting the method keeps the guarantee honest rather than qualifying it. An extension that needs to write to a remote is out of scope for this protocol and stays so until a capability with explicit idempotency and retry rules is designed for it.
HEAD is excluded for a different reason. Every fetch carries a dest,
and a HEAD has no body to put there; since the extension never sees the
response either, a HEAD intent would have no observable effect at all. A
method the protocol cannot give a meaning is worse than one it omits.
- Destinations. The host rejects loopback, link-local, and private-range
addresses. The check runs against the resolved address, not the hostname,
and runs again on every redirect hop. Whether an operator can re-admit a
specific destination, and at what scope, is host configuration this document
does not specify — the default is refusal, and nothing an extension declares
can change it.
- Request headers. The headers an extension supplies are carried, except
that Authorization, Cookie, and Proxy-Authorization are dropped. An
extension cannot smuggle a credential outward by naming it as a header, and
cannot override the one the host attached.
- Address pinning. The connection is dialled to the address that was
checked, not re-resolved from the name afterwards. Resolving, validating,
and then handing the URL to an HTTP client is the same check-then-use gap
described under path validation below: the client resolves again at dial
time and a name that answered publicly once can answer 127.0.0.1 the
second time. Pinning the dial to the checked address is what closes it, and
it applies per hop.
- Transport authentication. Pinning changes which address is dialled and
nothing else. The TLS handshake still verifies the certificate chain and the
hostname from the URL, not the pinned address, and a clone over ssh
verifies the host key before any credential is sent. Pinning is a
destination control and must never be mistaken for an identity one — an
implementation that dials an IP and lets the certificate follow it has
removed the guarantee this section exists for.
- Redirects. Followed to a bounded depth, each hop validated and pinned as
above. Credentials are attached per hop rather than carried across hops: a
redirect that changes host drops every Authorization header and cookie
instead of replaying it.
- Credentials. Supplied by the host from its own store, keyed to the
remote as the extension named it — scheme, host, and path — never to the
address that name resolved to, which is not stable behind a CDN or a
multi-address host. An extension names a remote, never a credential, and no
credential appears in the request the extension receives or the plan it
returns.
- Response exposure. The extension does not see the response. fetch
writes to dest and clone writes a checkout; both are effects the host
performs after the extension has already returned its plan, and the protocol
has no second call in which a response could be handed back. An extension
that must branch on a status code or a header needs a request-side
capability that does not exist yet, and is out of scope here.
- Bounds. A remote operation is bounded in bytes and in time, host-side,
on the same reasoning as the plan bound: the extension does not perform the
effect, so it cannot be trusted to limit it either. A response or checkout
that exceeds its byte bound, or an operation that exceeds its deadline, is
abandoned and the whole plan fails. The values belong with the
implementation; what belongs here is that no remote operation is unbounded.
- Cache. Keyed by extension identity as well as URL, so one extension
cannot read another's fetched bytes out of a shared cache.
Conflict rules
A plan is a set, not a script: the host does not apply intents in the order the extension emitted them.
An intent touches every path it names. For write, delete, fetch, and
clone that is one path — path or dest. For rename it is two, from
and to. Stating the rules over touched paths rather than destinations is
what makes them complete: a rename reads one path and writes another, and
both are paths another intent can invalidate.
Two paths are the same path when the target filesystem says they are, not when
their bytes match. The comparison folds case where the filesystem does and
normalizes Unicode where it does, so write README.md alongside delete
Readme.md is a conflict on macOS and Windows and two separate files on Linux.
Comparing text instead would let a plan pass validation on the maintainer's
machine and collide on a contributor's, which is the failure the whole section
exists to prevent.
Before staging, the host rejects the whole plan if either of the following
holds for any two intents. Both tests compare paths component by component
under the equality relation just described, so Skills/Foo is an ancestor of
skills/foo/SKILL.md wherever the filesystem says it is.
- They touch the same path. This covers a
writeand adeleteon one path, twowrites, tworenames sharing a source, arenameinto a path something else writes, and — because a cycle of renames must repeat a path pairwise — every rename cycle. - One's touched path lies beneath the other's. A plan holding
write aandwrite a/bis rejected rather than ordered, since one wantsato be a file and the other wants it to be a directory. So isrename a -> xalongsiderename a/b -> y, where whichever rename ran first would decide whether the second found anything to move.
Both rules share one exception, and it is what makes the common cases
expressible. A path that is, or lies beneath, the path named by a delete
intent does not conflict with that delete, unless the path in question is a
rename source. Deletes run before everything else, so the outcome is fixed
however the intents were emitted: delete skills/foo alongside write
skills/foo/SKILL.md drops a stale skill and regenerates it, and delete
vendor/x alongside clone dest=vendor/x re-clones from scratch, which
otherwise fails because go-git refuses to clone over an existing repository.
delete a alongside rename a/b -> c stays rejected, because the delete
would remove the path the rename reads.
The exception is about the delete's own path, not everything underneath it.
In delete a with write a/b and write a/b/c, the delete excuses each write
against itself, and the two writes are still judged against each other by rule
two — one wants a/b to be a file, the other a directory, so the plan is
rejected.
delete on a path that does not exist is a no-op rather than an error. That
alone does not make two nested deletes safe, because a missing path is not the
only way the second one can fail: if a is a regular file, delete a/b
returns ENOTDIR rather than ENOENT, so delete a with delete a/b
would succeed in one order and fail in the other. Deletes are therefore
applied ancestors before descendants, under the same path-equality relation
the conflict rules use rather than a byte-wise sort — otherwise delete
Skills/Foo and delete skills/foo/bar would not look nested on a
case-folding filesystem and the descendant could still run first. delete a
runs first, delete a/b then finds nothing and is a no-op, and the pair
produces the same tree whatever order the extension emitted it in.
Rejection is whole-plan and names the conflicting intents. There is no partial apply and no last-writer-wins.
Surviving plans are applied deletes first — ancestors before descendants —
then renames, then writes, with fetch and clone ordered as writes since
they produce content at a destination. Any two surviving intents that share or
nest a path do so only because a delete excused them, and deletes run first,
so no intent can remove or change the type of a path a later phase depends on,
and the same plan produces the same tree on every host regardless of emission
order. The ordering between kinds, and between deletes within their phase, is
fixed so that a plan is reproducible; it is not resolving conflicts, because
nothing that reaches it conflicts.
Replacing a subtree in one step is still not expressible. rename staged ->
skills/foo alongside write skills/foo/SKILL.md is rejected, and there is no
intent meaning "replace this directory", so an extension that needs atomic
subtree replacement rather than delete-then-write has no plan for it. That is
a known gap, recorded as a negative consequence in the ADR.
Path validation
Every location a plan names is resolved against a root the host chose, then
checked to be within it. That means path on write and delete, both ends
of a rename, and dest on fetch and clone — an intent that reaches the
filesystem is validated whatever the field is called. Absolute paths, ..
traversal, and any path traversing a symlink are rejected. Extensions never
learn the absolute root; they receive root-relative paths and return
root-relative paths.
Validation and commit share a file descriptor rather than a path. The host
opens the root once and resolves every plan path relative to that descriptor —
with openat2 under RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV
on Linux, and elsewhere by walking the path one component at a time and
refusing any component that redirects resolution. On Windows that means every
reparse point, not only the ones that are symbolic links: a junction or a mount
point redirects just as effectively and does not report itself as a symlink, so
a check that asks only "is this a symlink" passes both. RESOLVE_NO_XDEV is in
the Linux flag set for the same reason — without it a bind mount beneath the
root is traversed on Linux and refused on Windows, which is precisely the
per-platform divergence this rule exists to avoid. A path is therefore never
re-resolved between the check and the write, which is what makes a concurrent
symlink swap unexploitable rather than merely unlikely.
A path the conflict rules excuse — one at or beneath the path of a delete —
is validated in two parts. The prefix down to and including the deleted path is
resolved against the pre-plan tree like any other path, so a delete cannot be
used to reach outside the root. The components beneath it are not resolved
against the pre-plan tree at all, because that tree is about to lose them: if
a is a regular file, resolving a/b returns ENOTDIR, and the plan would be
refused over a state the delete removes. Those components are resolved after
the delete is staged, relative to the descriptor of the deleted path's parent,
where nothing remains beneath it; the host creates each one itself as a
directory, so none can be a symlink or a reparse point. Under this rule
delete a with delete a/b validates, and the descendant delete is the no-op
that ancestors-first ordering makes it. delete a with write a/b validates
too, and replaces the file a with a directory.
Symlinks are not followed at all, on any platform. Following only the ones
that stay beneath the root sounds narrower and is not expressible: under
openat2, RESOLVE_BENEATH rejects every absolute symlink whatever its
target, and RESOLVE_IN_ROOT reinterprets it relative to the root rather than
following it, so a rule phrased around "stays beneath" would mean something
different on Linux than in a component walk. Refusing all of them is the same
rule everywhere.
The installer Lola ships today agrees that a managed path must never be
written through a symlink, and issue #226 is why. It differs in what it does
next: unlink_symlink_if_present removes the link and writes a real file or
directory in its place, so the install continues, while _path_contains_symlink
defeats the idempotency shortcut so the user is asked first. A plan cannot do
that. Path validation rejects the intent, and rejection is whole-plan, so one
symlinked path fails the entire install rather than replacing the link and
carrying on.
Whether an extension should be able to express "replace this symlink", and whether the consent step that guards it today survives into the plan protocol, is left to the implementation issue. It is a real gap and not an oversight.
Plans are applied atomically: the host stages the whole plan, verifies it, then commits. A failure mid-apply rolls back to the pre-plan state.
Deletes are staged, not performed. A delete moves its target aside into a
staging area and commit is what discards it, because rollback has to restore
a subtree the host would otherwise no longer hold. That matters more since
deletes may now be paired with writes beneath them: delete skills/foo
followed by two writes into skills/foo is one plan, and if the second write
fails, the first write is undone and the deleted subtree is moved back. A
delete that ran destructively in its own phase would leave nothing to move
back and the guarantee would be false for exactly the plans the conflict
rules were relaxed to allow.
Rollback is a recovery mechanism, not a containment one — it cannot undo a write that landed outside the root, which is why the descriptor discipline above carries the guarantee.
Audit log
Every plan the host applies is logged before it is applied: the extension's
identity and version, its tier, the capabilities it was granted, and every
intent with its resolved paths, under a correlation id. What the plan could
not state in advance is appended as it happens — for fetch and clone, the
address actually dialled at each redirect hop, which only exists once
resolution and pinning have run.
Rejections are logged with the rule that rejected them. Content bodies are not
logged; their hashes are.
The outcome is logged too, after the fact, against the same correlation id as the pre-apply entry. There are three: committed; rolled back, naming the intent that triggered it; and rollback-failed, which means the tree is in neither the pre-plan nor the post-plan state. The third is the most important record the host can write and the reason the outcome is a distinct entry rather than a status appended to the first one. Without it a pre-apply entry reads as a change that landed when the plan may have been rolled back entirely, which makes the log actively misleading rather than incomplete.
The log is the only control tier 2 has that tier 1 does not get for free. A tier-1 module cannot act outside its plan, so its log is a convenience. A tier-2 extension is trusted rather than confined and may act before the host ever sees a plan, so the log is the record of what it declared it would do, against which its actual effect on the tree can be compared. That is what the ADR means by launching and auditing a native extension rather than confining it, and it is why the log covers both tiers rather than only the enforced one.
Capabilities
An extension declares what it needs in its manifest. The host grants the narrowest set that satisfies the declaration and denies anything undeclared. A declaration naming a capability the host does not define is refused at load rather than trimmed to the subset the host recognises, so an extension built against a newer capability set fails loudly instead of running with less authority than it expects. Absent declaration means no capability.
capabilities:
- net.http # host performs the fetch; extension supplies the intent
- net.git # host performs the clone
| Capability | Grants | Notes |
|---|---|---|
| (none) | plan-only operation | the expected case for target extensions |
net.http |
fetch intents accepted |
host executes; extension never opens a socket |
net.git |
clone intents accepted |
host executes via go-git |
There is no filesystem capability. Filesystem access is always mediated by the plan.
The shim
The host re-executes its own binary as a hidden subcommand:
The subcommand is registered on the root command with Hidden: true so it does
not appear in help or completion. The parent passes the request on the child's
stdin and reads the plan from its stdout; stderr is captured and surfaced as
extension diagnostics.
--caps does not change what the child does. Both defined capabilities are
host-executed and the child never interprets the plan it forwards, so its
confinement is identical whatever it is granted; the flag is carried for the
audit record and for the case that does not exist yet. If a capability is ever
added that the module itself must exercise, this is what would carry it, and
the child's confinement would stop being uniform. Until then the enforcement
point for every capability is the host, and the steps below are the same for
every extension.
The child, in order:
- Reads the module artifact into memory. This happens first because the artifact is named by path on the command line, and the next step makes that path unreachable. It is also the one allocation that precedes every other limit, so it carries its own: the child opens the artifact, refuses it if the open file's size exceeds the maximum module size, and reads through a reader capped at that size plus one byte, refusing the module if the extra byte arrives. The stat alone is not enough, since the file can grow between the check and the read; the cap alone would still allocate for a pathological size. A refused artifact never reaches wazero.
- Applies OS confinement. On Linux this is Landlock restricting the process to no filesystem access at all, which is possible precisely because the module bytes are already in memory and the request arrives on an already-open pipe. On macOS and Windows this step is a no-op and the guarantee rests on wazero.
- Instantiates the module from those bytes with wazero, configured with no preopened directories, no environment variables, and no argv. The request reaches the module through the ABI call described below, not through its process environment.
- Runs the module under a memory ceiling and a wall-clock timeout, the timeout enforced by cancelling the module's context. Those two are the whole runaway-execution budget: wazero exposes no instruction or fuel metering, so a module that spins is stopped by the clock and by nothing finer.
- Copies the plan out of module memory, refusing a plan whose declared length exceeds the maximum plan size before copying anything, then writes it to stdout and exits.
The three limits in steps 4 and 5 stop the module, not the child. The memory ceiling and the wall clock terminate the module's execution, the plan-size check refuses to copy an oversized plan out, and in each case the shim itself survives to report which limit was hit and exit cleanly. Reporting which limit was hit, rather than only that the child died, is why these limits sit inside the shim at all. A child is killed only by the host, and only two things do it: the stdout bound, and the outer deadline described below. The stderr cap does neither, so a plan is never discarded over a noisy log. Extension failure never leaves partial state, because nothing was applied.
The maximum plan size is enforced twice, at two different layers, because there are two ways to overrun it. The shim applies it in step 5 against the length the module declares, so an oversized plan is never copied out of linear memory in the first place. The host applies it again to whatever arrives on a child's stdout, through a bounded reader that caps total bytes, intent count, and the size of any single content field, and kills a child that exceeds any of them. The host's copy is the one that covers tier 2, which has no shim at all: a tier-2 extension is an ordinary process on the other end of the same pipe, and an unbounded native writer would otherwise exhaust host memory or block it. Neither layer is redundant — the shim bound protects the shim, and the host bound is the only one a tier-2 extension ever meets.
Bytes are not the only way a child can fail to finish, and tier 2 does not inherit the shim's clock. The wall-clock deadline in step 4 belongs to the shim and covers module execution only, so a tier-2 process has none at all, and a native extension that holds stdout open while writing nothing stays under every byte cap forever. The host therefore runs its own deadline against any child it launched, tier 1 or tier 2.
That deadline is strictly the outer one. It starts at spawn and has to cover reading the artifact, applying confinement, the module's own budget, and the shim writing its result and exiting, so it cannot equal the step-4 value: set equal, the host would kill every shim at the instant the inner timeout fired and the clean "module timed out" diagnostic would never be produced. The byte bounds compose at equal values because the inner check short-circuits; deadlines do not, which is why this one is stated as an outer bound rather than the same bound applied twice. A child that exceeds it is killed as a process group and reaped, since a native extension can leave a grandchild holding the write end of a pipe that would otherwise never close.
A process group is not a containment boundary: a descendant that calls
setsid() or setpgid() leaves it and survives the kill, still holding the
pipe. The host therefore never waits for end-of-file after the deadline. Once
it has signalled the group it closes its own read ends, reaps the direct
child, and stops; cleanup after the kill is bounded by a short grace period,
not by the descendants. That is what keeps the host live on every platform.
Descendant containment is layered on top where the platform offers it:
- Linux. Where the host has a delegated cgroup v2 subtree, each tier-2
child starts in a fresh child cgroup, and the kill writes
1to itscgroup.kill, which signals every process in it whatever session or group it has moved to. Without a delegated subtree the host marks itself a child subreaper withprctl(PR_SET_CHILD_SUBREAPER), so descendants orphaned by the kill are reparented to the host, which kills and reaps them until none remain or the grace period ends. - Windows. Each tier-2 child runs in a job object created with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEand without any breakaway flag, so its descendants inherit the job and closing the job's handle terminates them. The child must be in the job before it executes any code. Assigning it afterCreateProcessreturns leaves a window in which it can start descendants outside the job, and the kill then misses them. The host therefore creates the child withCREATE_SUSPENDED, callsAssignProcessToJobObject, and only then resumes its primary thread, or creates it already in the job throughPROC_THREAD_ATTRIBUTE_JOB_LISTwhere the platform supports it. A child whose assignment fails is terminated before it is resumed. - macOS. No unprivileged equivalent exists, so the process group kill and the bounded cleanup are all there is. A descendant that escapes the group can outlive the install; that is the tier-2 trust model, not a gap in it.
Stderr is bounded too, though not with the same remedy. The shim surfaces a child's stderr as diagnostics, so it is attacker-controlled output the host accumulates: it gets a byte cap, and a child that exceeds it has its diagnostics truncated with a marker and is allowed to continue, because losing the tail of a log is not a reason to discard an otherwise valid plan. The host keeps draining the pipe after the cap rather than stopping, since a reader that stops reading is what makes a child block on a full pipe. A bounded stdout beside an unbounded stderr would just move the problem to the other channel.
Tiers
| Tier 1 | Tier 2 | |
|---|---|---|
| Artifact | .wasm module |
native binary |
| Runtime | wazero, WASI preview 1 | OS process |
| Filesystem | none — plan-mediated | none granted, not enforced |
| Network | net.http / net.git intents only |
as tier 1; direct access not enforced |
| Install | default | explicit opt-in, valid signature required |
| Listed as | wasm in lola ext ls |
native in lola ext ls |
Tier 2 speaks the identical plan protocol over the identical pipe. The difference is enforcement, not interface. An extension that confines itself to the plan ABI is promoted from tier 2 to tier 1 by recompiling to a WASI preview 1 target, with no code change. An extension that reaches the filesystem or the network directly — which nothing in tier 2 stops it doing — is not promotable until those calls become intents.
Install hooks are the first tier-2 extension. The host launches a hook script through the same child lifecycle as any other tier-2 process — deadline, stdout and stderr bounds, descendant cleanup, audit entry — passing the environment variables hooks receive today. A hook writes no plan: its exit status is the result, an empty plan is what the host applies, and anything the script prints on stdout is diagnostics rather than a plan to parse, bounded the same way.
Authoring toolchain
| Language | Target | Notes |
|---|---|---|
| Go | GOOS=wasip1 GOARCH=wasm |
native since Go 1.21 |
| Rust | wasm32-wasip1 |
rustup target add wasm32-wasip1 |
| JavaScript | javy |
compiles JS to a WASI p1 module via QuickJS; small artifacts |
| Python | tier 2 | CPython cannot target WASI p1 without shipping an interpreter |
The Python asymmetry is documented in user-facing docs rather than hidden. A
Python extension is a normal binary or script; it is signed, opt-in, and
reported as native.
ABI
WASI preview 1 has no interface-type mechanism, so the module contract is Lola's to define:
Every call below is made by the shim child, which is the process that embeds wazero. The host never touches module memory; it sees only what the child writes to stdout.
- The module exports
lola_run(req_ptr, req_len) -> i32, returning a status code. The child allocates the request buffer through the module's ownlola_alloc(size) -> ptr, copies the request bytes in, and passes that pointer and length as the call's arguments, so the module is never left guessing where its input is or how far it extends. - The child reads the plan back through
lola_plan_ptr() -> ptrandlola_plan_len() -> i32, following the pattern established by Extism. It reads them only afterlola_runreturns success, and rejects any pair whose extent falls outside the module's linear memory or exceeds the maximum plan size. A length of zero is an empty plan, which is a distinct outcome from a failure status. - Both buffers live in the module's linear memory and are owned by the module. The request buffer stays valid for the duration of the call; the plan buffer stays valid until the instance is discarded. The child copies the plan bytes out and never writes to that region.
- Request and plan are JSON. The volume is small (a module list and a file plan), so a binary encoding is not worth the tooling cost.
- The ABI carries a version integer, exported by the module. The child reads
it before calling
lola_run, refuses a module whose version it does not implement, and reports the mismatch to the host naming both versions.
Testing
- Plan validation is unit-tested against hostile inputs directly:
..traversal, absolute paths, symlinks pointing outside the root, symlinks pointing inside it, and paths that only escape after the second resolution. - Redirecting components are tested per platform: on Windows a junction and a mount point as a path component, on Linux a bind mount, asserting each is refused rather than followed. A symlink-only check passes all three, which is why they get their own cases rather than riding on the symlink one.
- Symlink policy is tested on every platform and asserts the same outcome on each: a plan path traversing a symlink is rejected whether the link target is inside the root or outside it. Both cases are asserted precisely because the rule is stricter than the installer's, and a platform that quietly followed one of them would be the divergence this rule exists to prevent.
- Path races are tested by swapping a validated component for a symlink between validation and commit, from a concurrent goroutine, and asserting the write lands at the inode that was validated rather than at the swapped-in target. The commit succeeding against the original is the point — a test that asserts failure would be satisfied by an implementation that re-resolves at commit time, which is the race itself.
- Conflict rejection is tested per rule, in the vocabulary of the rules.
Same touched path: a
writeand adeleteon one path, tworenames sharing a source, a three-hop rename cycle, and afetchwhosedestis another intent's path. Nesting:write awithwrite a/b,rename a -> xwithrename a/b -> y, anddelete awithrename a/b -> c. Each asserts whole-plan rejection naming the conflicting intents. The exception is tested the other way round —delete skills/foowithwrite skills/foo/SKILL.mdmust be accepted and must apply the delete first. - Path equality is tested on a case-insensitive filesystem:
write README.mdwithdelete Readme.mdis one path there and two on Linux, so the same plan must be rejected on macOS and Windows and accepted on Linux. - Delete rollback is tested by failing the last write of a plan that deletes a subtree and writes into it, asserting the subtree is back and the earlier writes are gone.
- Delete ordering is tested with
delete aanddelete a/bwhereais a regular file, asserting the plan validates and applies in either emission order. Without ancestors-first ordering the descendant delete returnsENOTDIR, so this fails in exactly one of the two orders; without delete-aware validation it is refused before staging in both. The same fixture withdelete aandwrite a/bmust validate and leaveaa directory holdingb. - Child lifecycle is tested with a tier-2 extension that holds stdout open
and writes nothing, asserting the host kills and reaps it on the deadline
rather than waiting, and with one that floods stderr, asserting diagnostics
are truncated and the host neither blocks nor grows without bound. A third
forks a descendant that calls
setsid()and holds stdout open; the host must return within the deadline plus the grace period on every platform, and on Linux and Windows the descendant must be gone afterwards. - Module size is tested with an artifact one byte over the maximum and with one that grows past it between the size check and the read, asserting both are refused before instantiation.
- Audit log is tested by asserting an applied plan logs every intent with
resolved paths, that a rejected plan logs the rule that rejected it, and
that no
contentbody and no credential appears in the output. A plan that rolls back is asserted to log the rollback against the same correlation id as its pre-apply entry, so the pair cannot be read as a successful change. - Dry-run is tested by asserting
--dry-runprints a tier-1 plan without applying it, refuses a plan-returning tier-2 extension rather than running it, and reports an install hook without running it. - Install hooks are tested by running an unmodified pre- and post-install
hook as tier 2, asserting it receives today's environment variables, that a
failing pre-install hook still aborts the install and a failing post-install
hook still only warns, and that each run has an audit entry naming the hook
as a
nativeextension. - Transport authentication is tested by pinning a resolved address and presenting a certificate valid for a different hostname, asserting the handshake fails — pinning must not be able to launder a name mismatch.
- Remote policy is tested with a redirect from an allowed host to a
private-range address, asserting the request is refused; with a cross-host
redirect, asserting the
Authorizationheader is not replayed; with aPOSTfetch intent and with aHEADone, asserting whole-plan rejection at validation for both —HEADis the method most likely to be waved through as harmless; and with a name whose second resolution returns loopback, asserting the pinned address is dialled rather than the rebound one. - Confinement is tested with purpose-built hostile modules — one that
attempts to open
/etc/passwd, one that attempts a socket, one that allocates without bound, one that never returns. Each must fail in the expected way, and each is a regression test. - Tier parity is tested by compiling the same fixture extension to both tiers and asserting identical plans, which is what makes promotion a recompile.
- Atomicity is tested by injecting a failure partway through apply and asserting the tree matches its pre-plan state.