Skip to content

External Action Boundary Evaluation P1b: local doubles, isolation, reference guard and XM gate - #22

Merged
mikko-lab merged 18 commits into
mainfrom
claude/external-actions-p1b
Oct 14, 2026
Merged

mikko-lab merged 18 commits into
mainfrom
claude/external-actions-p1b

Conversation

@mikko-lab

@mikko-lab mikko-lab commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Work order "External Action Boundary Evaluation P1b". Draft; no merge, release, tag or version bump.

Results cover only the local synthetic reference guard and its planted XM mutants against local doubles under the profile external-actions-p1b-local-reference. They are not a security result for any other guard or a real service.

Start and head

  • Start main: 8da7a608be603849e14f479ef774a28f8543e3c8. Main did not move during the work (re-checked before this update).
  • Head: 1733a35 (fix 30c2a2a + its regression test).

Status

Under independent review. Three review rounds have been applied (see Review follow-up). Round 3 resolves a same-SHA CI difference, scopes the trust-boundary and descendant-prevention wording to the narrower, now-approved claim, and adds a dedicated regression for the new window-closure rule. Merge is not requested; final SHA approval awaits both CI runs (push and pull_request) on this head.

Pins (unchanged by the review fixes)

Item SHA-256
P1b profile profiles/external-actions/p1b-local-reference.profile.json b279917161905d34cf0a8aff30940269e6b2ed6fe9796476d849ca40d5ee2843
Run configuration p1b-local-reference.run.json 5ecbce524721ac10ddb4aa431f79eb00e6977cfb73566c329a090abb77b40058
Guard mutant manifest mutations/external-actions/guard-mutants.json 3ea1d0df4329d55db1f81fa01d6c258156b5c886772b6e4db992c52dc0f21ca8
Corpus (unchanged) d838caab3ccaf2d3809d6c0137e00e90f181dee6225338648a6f928027ce5442

The decisions (bdb15f2), the XM assertions declared before the precheck (ced72c4) and the pinned precheck results (78b615e) were each committed in order. Round 3 changes the harness timing of the provisional closure, the documentation and the tests only; the guard, the evaluator rules and the pinned per-case results are unchanged, and the gate still reproduces them.

Environment

  • CI: ubuntu-24.04, Node 22 and Node 24.
  • Isolation: per-identity network namespaces, veth, mount namespaces; iptables 1.8.10 (nf_tables); sch_netem; cgroup v2 with cgroup.kill; setpriv; Node's permission model (child-process and worker creation denied) for the in-cgroup processes.
  • Users ea-exec (E), ea-dbl (D), ea-obs (H): none root, none in an admin group, each denied by sudo -l. The host's general security settings are untouched.
  • The development container used for this work has no sch_netem; there the full profile fails the environment check with exit 2 and no case is skipped, so retry-delayed and its mutants are exercised only in CI.

What is in it

  • Recipient doubles P and Q (D), with hash-chained journals, fences, unix-socket control and trackers; modes immediate, failing, deferred_persist, and the retry-once hold.
  • Canary double, isolation probes (E), netem backlog reader (H).
  • Executor (guard host, E) with one forwarder per network identity; case runner (driver and collector, H); root orchestrator (namespaces, filter, netem, cgroup, relay). The closure signal is a one-way FIFO the executor holds read-only.
  • Run validity layer, canonical comparison form, a P1b report kind, the reference guard with rule blocks, ten rule-confined XM patches, and the whole-run gate.

Shared changes outside the P1b directory: the observation source enum gains local_doubles; the report schema pairs the evidence kind with the source; buildReport takes a kind parameter defaulting to P1a's; the evaluator lets a keep-alive group share a failed window. P1a bytes are unchanged.

Review follow-up

Round 1 (10db7d8).

  • Finding 1 — the guard could pick another attempt's network identity. The forwarding right is bound to the attempt the harness dispatched; the executor routes every forward (and deferred, signal-triggered send) through that attempt's own forwarder, and each forwarder serves only its own identity's attempts.
  • Finding 2 — natural quiescence did not cover the whole process tree. The in-cgroup processes run under Node's permission model with child-process and worker creation denied, so a guard that uses the interface creates no descendant process and the harness-started set is the whole tree. The executor confirms the prevention at startup; the validity layer fails the case otherwise. Regression PR8 added.

Round 2 (520e302): two in-process bypasses of the identity binding, both closed with regressions.

  • The AsyncLocalStorage that carries the forwarding right is now module-private (not exported), so the in-process guard has no reference to open a binding for an identity it was not dispatched; a guard's own store cannot change the executor's.
  • afterSignal takes the deferred send's identity only from the active dispatch binding (never the guard-supplied id), and refuses a deferred send registered outside a dispatch.

Round 3 (30c2a2a, 1733a35): same-SHA CI difference, documentation scope, and a regression.

  • Same-SHA CI difference. On 520e302 the push CI run passed but the pull_request run failed the P1b mutant gate (exit 2) on Node 24. The confirmed difference: XM12 on retry-once and retry-delayed produced :late effect findings that the pins do not carry (the verdict stayed VIOLATION, but the run did not reproduce the pinned timing). Root cause, from the raw observations: the provisional window was fenced as soon as the doubles reported no open connection, so XM12's fire-and-forget forward under a reported DENY raced the fence — its effect at the recipient was journaled before or after the fence by host timing alone. The fix fences a group's window only once every forward the group's attempts started has settled (a forward_done for each forward_started), so such a forward has reached the recipient and its effect precedes the fence. The delay-element attempt is excluded on purpose so that the retry-delayed order proof still observes its effect after its own fence. Lateness is neither removed from the comparison nor re-pinned: genuinely late effects are unchanged (the deferred-persist store still lands after the response and stays :late; an after-signal send has not started at fence time). A new forward_in_flight window failure reason covers a forward that never settles within T_w.
  • Regression (1733a35). The closure decision is extracted into two pure, exported helpers of the case runner (groupForwardsSettled, windowClosure) with the loop behaviour unchanged, and a deterministic test pins the rule: an in-flight forward blocks the fence even with no open connection; forward_done releases it; an unsettled forward fails as forward_in_flight at T_w; the delay-element attempt is excluded while the retry is waited on; an after-signal send does not block closure and stays late. A sensitivity check of the old logic — temporarily reverting the closure rule in the working tree to close on no-open-connections alone — makes the two race tests fail, while all four pass on the fixed head; this is a local sensitivity test of the pre-fix rule, not a run against the unmodified 520e302 commit. It pins the fix rather than only the current code.
  • Documentation scope. decisions.md and p1b.md now state the demonstrated claim consistently: P1b evaluates a named reference guard and XM mutants that use the interface; preventing arbitrary in-process guard code from forging identity, manipulating the harness, or creating descendant processes is not demonstrated. The earlier, undemonstrated general guarantee that a direct forwarder misuse yields a detectable missing/unexpected effect rather than a silent misattribution is removed, and the one place describing descendant-process prevention as absolute is scoped to a guard that uses the interface.

Local validation of Round 3

Typecheck/build OK; 55/55 external-actions tests (34 P1a + 21 P1b) pass; the oracle-boundary check passes. With no local sch_netem, the XM12 cases that do not need it were run directly: retry-once, fill-no-submit and deferred-persist-forbidden reproduce the pins exactly (retry-once has no :late; deferred-persist-forbidden keeps its single unexpected_submission_received@P:late), stable across repeated retry-once runs; the reference guard stays clean on the witnesses and controls; XM13a keeps its :late findings and XM13b its T_q overrun. retry-delayed needs sch_netem and is exercised in CI.

Not done

P2–P5, alias extensions, ACS integration and model runs.

claude added 18 commits October 13, 2026 21:09
Timing and retry values, the retry hooks and order proofs, the keep-alive
group window, isolation and privileges, recipient doubles, closure, the
reference guard rules and XM mutants, the gate, the P1b profile, run
record, schema extension and the canonical comparison form.
…ference guard and XM patches

Recipient doubles P and Q with hash-chained journals, fences and control
sockets; canary double; netem backlog reader; executor (guard host) and
per-identity forwarders; case runner (driver and observation collector);
root orchestrator with per-case namespaces, packet filter, netem, cgroup
and mount hiding; environment check; run validity layer; canonical
comparison form; P1b profile and run configuration; observation source
and report kind extended without changing P1a bytes.
…recheck

Witness cases, required verdicts, required decision and effect findings
and the evidence kind of each mutant, derived from the cases, the oracle
and the mutant semantics. Exact pins and named deviations are left empty
for the precheck.
…s and CI job

The gate builds each XM mutant from its rule-confined patch and checks the
whole run against the declared assertions and exact pins; precheck output
never passes. Privileged regressions PR1-PR7 inject missing isolation, a
writable or missing observation channel, a failed provisional closure, a
forbidden effect beside cleanup and a separate retry state. The CI job runs
the environment check, two control runs with a canonical comparison, the
recorded-CLI comparison, the mutant runs, the gate and the regressions.
…y link exists

On the CI kernel the link-local IPv6 addresses of the veth links changed
state during longer cases, which the configuration record rightly reported
as an isolation change. The record now also names the changed lines.
…eeps the normalized configuration lines

The netem qdisc shows a random seed that differs between runs (it stays the
same within a case, so the before/after check is unchanged). The canonical
form now keeps the normalized configuration lines rather than their hash, so
a difference names the changed line.
…iations

Exact per-case pins of the control and of every mutant over the full P1b
profile, from the precheck of CI run 38503539437 (identical on Node 22 and
Node 24). Every declared witness assertion held. Named deviations: XM11's
harness condition in retry-once and retry-delayed, and XM13b's T_q overrun
in the three cases with a denied HTTP-tool request. The declared assertions
are unchanged.
… attempt and prevent executor descendant processes

Review finding 1: the guard could route one attempt's forward through
another attempt's forwarder by changing the request id, so a per-attempt
address did not by itself give unforgeable attribution. The executor now
binds the forwarding right to the attempt the harness dispatched, carried
across the guard's async handling (and its deferred, signal-triggered
sends) by an AsyncLocalStorage store, and routes every forward through that
attempt's own forwarder whatever id the guard supplies. Each forwarder
serves only its own identity's attempts and rejects any other. A component
test with substituted forwarder sockets reproduces the original attack and
shows it is now bound.

Review finding 2: natural quiescence covered only the orchestrator-started
processes plus an empty cgroup, so a descendant that exited by a signal
could pass unseen. The executor and forwarders (the only processes in the
case cgroup) now run under Node's permission model with child-process and
worker creation denied, so no descendant process can be created and the
harness-started set is the whole process tree. The executor confirms the
prevention at startup and the run validity layer fails the case otherwise.
Regression PR8 injects a guard that tries to spawn a child and asserts the
denial.

decisions.md canonicalization and the executor-quiescence and per-attempt
identity decisions are aligned with the implementation; p1b.md updated.
… and state the trust boundary

Review follow-up on finding 1. The guard runs in-process, so an in-process
binding must not be reachable and no guard-supplied id may be a fallback:

- The AsyncLocalStorage that carries the forwarding right is no longer
  exported; it is module-private, so the guard has no reference to open a
  binding for an identity it was not dispatched. A guard's own
  AsyncLocalStorage cannot change the executor's.
- afterSignal takes the deferred send's identity only from the active
  dispatch binding. A deferred send registered outside a dispatched attempt
  is refused (recorded as a technical error), never bound to the id the
  guard passed.

Regressions cover both: a guard that wraps the forward in its own
AsyncLocalStorage still routes through the dispatched attempt's forwarder;
the binding store is not exported; a deferred send naming another attempt
binds to the dispatch and a signal for the other attempt never fires it; a
deferred send with no active dispatch is refused.

decisions.md and p1b.md now state the trust boundary precisely: attribution
is grounded in the peer address the doubles record (per-identity network
namespaces, outside the guard process); the env interface makes the right
unforgeable for a guard that uses it (the reference guard and every XM
mutant do); P1b does not sandbox arbitrary in-process guard code, which
would need the guard and each identity's egress in separate processes or
namespaces. This narrower claim is recorded for separate approval.
…oup's forwards settle

The provisional closure of a keep-alive group's window used to fence as
soon as the doubles reported no open connection. A forward the guard
started but had not finished — notably XM12's fire-and-forget send under a
reported DENY — then raced the fence: its effect at the recipient was
journaled before or after the fence by host timing alone, so XM12 on
retry-once and retry-delayed produced `:late` effect findings on some hosts
and not others, and the mutant gate's exact pins differed between the push
and pull_request CI runs on the same SHA.

Close the window only once every forward the group's attempts started has
settled (a `forward_done` for each `forward_started`), so such a forward
has reached the recipient and its effect is journaled before the fence. The
delay-element attempt is excluded on purpose: its traffic is held by the
netem element so its effect is observed after its own fence, which the
retry-delayed order proof relies on. Genuinely late effects are unaffected:
the deferred-persist store still lands after the response, and an
after-signal send has not started at fence time.

Add the `forward_in_flight` window failure reason (type, observation
schema) for a forward that never settles within T_w.

Also scope the trust-boundary and descendant-prevention wording in
decisions.md and p1b.md to the demonstrated claim: P1b evaluates a named
reference guard and XM mutants that use the interface; it does not
demonstrate preventing identity forgery, harness manipulation or
descendant-process creation by arbitrary in-process guard code, and in
particular does not demonstrate that a direct forwarder misuse surfaces as
a detectable missing/unexpected effect rather than a silent misattribution.
… rule

Extract the provisional-closure decision into two pure, exported helpers of
the case runner — `groupForwardsSettled` (every `forward_started` the group
waits on has a `forward_done`, the delay-element attempt excluded) and
`windowClosure` (close / wait / fail with a reason) — leaving the loop's
behaviour unchanged, and add deterministic tests for the rule that closed
the XM12 `:late` race:

- an in-flight forward of the group blocks the fence even when the doubles
  show no open connection (the fire-and-forget-under-DENY case);
- `forward_done` releases the wait; a forward that never settles fails the
  window as `forward_in_flight` at T_w, distinct from `connections_open`
  and `fence_missing`;
- the delay-element attempt (retry-delayed a1) is excluded from the wait
  while the retry a2 is waited on;
- a send scheduled for after the closure signal has not started at fence
  time, so it does not block closure and stays genuinely late.

Reverting the rule to close on no-open-connections alone (the behaviour
before the fix) makes the first two tests fail, so the regression pins the
fix rather than only the current code.
@mikko-lab
mikko-lab marked this pull request as ready for review October 14, 2026 09:23
@mikko-lab
mikko-lab merged commit 4aab617 into main Oct 14, 2026
71 of 72 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants