Skip to content

feat: [ENG-2243] AutoHarness V2 dual-VM isolation integration test#501

Merged
danhdoan merged 2 commits intoproj/autoharness-v2from
feat/ENG-2243
Apr 21, 2026
Merged

feat: [ENG-2243] AutoHarness V2 dual-VM isolation integration test#501
danhdoan merged 2 commits intoproj/autoharness-v2from
feat/ENG-2243

Conversation

@danhdoan
Copy link
Copy Markdown
Collaborator

Summary

  • Problem: brutal-review item A3 enumerated 5 cross-VM attack vectors that Phase 3 must contain — global pollution, closure leak, mutable parameter, prototype pollution, and error stack-trace escape. Unit tests (Task 3.2 for the builder, Task 3.4 for graceful degradation) prove per-component correctness; none prove the full pipeline (SandboxService.loadHarness → module builder → injected context) survives an adversarial harness.
  • Why it matters: this is Phase 3's security proof-of-work. If any attack vector here ever succeeds, the harness sandbox is structurally compromised and the feature cannot ship. Closing A3 was an explicit Phase 3 ship-gate requirement.
  • What changed: new test/integration/agent/harness/isolation.test.ts — 5 tests, one per A3 attack vector, exercised end-to-end against real HarnessStore + HarnessModuleBuilder + SandboxService.loadHarness. Each test asserts the breach doesn't reach Node's scope AND the pipeline stays healthy afterwards.
  • What did NOT change (scope boundary): No new source code. No test helpers. No mutations to the evaluator, loader, or store. This is a pure coverage PR against the shipped pipeline.

Type of change

  • New feature
  • Bug fix
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Closes ENG-2243
  • Closes brutal-review item A3 (dual-VM isolation against 5 attack vectors)
  • Depends on (merged): ENG-2239 (deep-freeze + strict-mode in HarnessModuleBuilder), ENG-2240 (SandboxService.loadHarness), ENG-2241 (graceful-degradation tests for A4)
  • Completes Phase 3 — with this PR merged, all 5 Phase 3 tasks ship. Phase 4 (templates + bootstrap) is Phat's parallel stream; Phase 5 (mode selector) is unblocked.

Root cause (bug fixes only, otherwise write N/A)

  • Root cause: N/A
  • Why this was not caught earlier: N/A

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s): test/integration/agent/harness/isolation.test.ts
  • Key scenario(s) covered (5 attacks, each asserting "leak contained" + "pipeline healthy"):
    • Attack 1 — Global pollution: harness globalThis.X = 'touched' → Node's globalThis[X] is undefined (V8 per-context realm)
    • Attack 2 — Closure leak: harness returns closure that tries stolen.curate = hijackFn → outer invocation throws VM-realm TypeError (frozen strict-mode write)
    • Attack 3 — Mutable parameter: harness writes ctx.env.commandType = 'hacked' → deep-freeze throws; wrapper surfaces curate() failed: ...
    • Attack 4 — Prototype pollution: harness Object.prototype.X = 'polluted' → Node's ({} as any).X is undefined (V8 per-context prototype chain)
    • Attack 5 — Stack-trace escape: harness throws Error with capturedThis: globalThis AND capturedArbitrary: {secret: 'do-not-leak'} → wrapper's fresh new Error() strips ALL attached properties; caught error has neither

User-visible changes

None. Pure test coverage. No consumer of the tested paths changes behavior.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording

Before this PR, the test file didn't exist; the 5 attack vectors had no integration-level coverage. After: all 5 pass in 59ms. Full suite: 6710 passing / 0 failing.

$ perl -e 'alarm 30; exec @ARGV' npx mocha test/integration/agent/harness/isolation.test.ts --timeout 10000
  dual-VM isolation — brutal-review A3
    ✔ 1. Global pollution: harness `globalThis.X = ...` does NOT reach Node globalThis
    ✔ 2. Closure leak: returned closure cannot mutate captured ctx.tools
    ✔ 3. Mutable parameter: harness cannot mutate its own ctx.env
    ✔ 4. Prototype pollution: harness `Object.prototype.X = ...` does NOT reach outer Object.prototype
    ✔ 5. Stack-trace escape: attached properties on thrown errors do NOT escape the wrapper
  5 passing (59ms)

Checklist

  • Tests added or updated and passing (npm test) — 5 new tests; full suite 6710 passing / 0 failing
  • Lint passes (npm run lint) — 0 errors, 226 pre-existing warnings
  • Type check passes (npm run typecheck) — exit=0
  • Build succeeds (npm run build) — exit=0
  • Commits follow Conventional Commits format — feat: [ENG-2243] ...
  • Documentation updated (if applicable) — task doc at features/autoharness-v2/tasks/phase_3/task_05-isolation-integration-test.md (research repo) drove the scope; the in-memory-vs-mkdtemp choice + health-check strengthening flagged below for post-merge task-doc tightening
  • No breaking changes (or clearly documented above) — test-only addition
  • Branch is up to date with main — targets proj/autoharness-v2, not main

Risks and mitigations

  • Risk: If any isolation invariant ever regresses, the test would immediately fail — which is the whole point — but a regression would also mean the harness sandbox is exploitable from attack code. The test is the gate, not the fix.

    • Mitigation: The afterEach hook defensively deletes both sentinel keys (ATTACK_1_GLOBAL_KEY on globalThis, ATTACK_4_PROTO_KEY on Object.prototype). If a failure ever lets the pollution through, other tests in the suite don't inherit it — the failure stays localized to the one broken test.
  • Risk: Cross-realm instanceof fragility — the pattern error instanceof TypeError is a common test idiom that silently fails when the error is thrown inside vm.createContext. Future maintainers who copy-paste from this file might hit the same trap elsewhere.

    • Mitigation: The in-source comment on Attack 2 explicitly documents the realm boundary and the error.name === 'TypeError' idiom as the fix. Any future reviewer reading the comment learns the gotcha. The stronger assertion (name + message-regex match) is less brittle than a single instanceof check anyway.
  • Risk: capturedArbitrary property on Attack 5 — the wrapper's "fresh Error construction" strips ALL attached properties today, but a future optimization that preserves some common properties (e.g., cause, code) could re-introduce a leak surface.

    • Mitigation: Attack 5 attaches TWO properties (a known capturedThis and an arbitrary capturedArbitrary) — the test would catch a regression that preserved EITHER one. If a future optimization needs to preserve specific properties (like error.cause for upstream diagnostics), the test should be updated to verify only attacker-attached properties drop, not the allowlisted ones.

Notes for reviewers

If this PR ever fails, do not retry, debug. Every passing assertion here proves a structural invariant of V8's vm module or the wrapper we layered on top. A flake would more likely mean the invariant broke (bug) than the test being racy. None of the assertions time-depend; the file runs in 59ms.

Cross-realm instanceof gotcha (Attack 2) was the one interesting discovery during implementation. The TypeError thrown by strict-mode frozen-write comes from the VM's realm with the VM's TypeError.prototype, distinct from Node's outer-realm class. error instanceof TypeError fails silently. Fix: error.name === 'TypeError' (string, realm-agnostic). Comment in-source explains. If another test in the repo ever does instanceof TypeError against a VM-thrown error, expect the same surprise.

Attack 5's dual-property attachment is deliberate. A single capturedThis: globalThis would prove "wrapper doesn't pass through globalThis references." The second capturedArbitrary: {secret: 'do-not-leak'} proves the wrapper strips ALL attached properties via new Error() construction — not a selective whitelist. If a future wrapper optimization preserves cause or similar, this test would catch it and force an explicit decision.

expectSandboxHealthy() strengthened vs. task doc. The original task-doc AC said "subsequent raw tools.* calls still work" but didn't specify HOW to verify. Early implementation used 2 + 2; reviewers correctly flagged that this only tests JS eval, not tools namespace integrity. Current health check runs a single sandbox expression that asserts both {math: 2 + 2, hasTools: typeof tools === 'object' && typeof tools.readFile === 'function'} match — snapshot proves JS + tools in one assertion. If the attack had corrupted ToolsSDK construction (e.g., by polluting something it depends on at the module level), hasTools: true would flip.

Storage choice: in-memory, not mkdtemp. Task doc suggested mkdtemp + disk cleanup pattern from Phat's Task 2.5. Isolation attacks don't touch the filesystem — the harness runs in a VM context, not a subprocess — so in-memory FileKeyStorage removes tmpdir lifecycle concerns entirely. Cleaner setup, same isolation.

Phase 3 closes with this PR. All 5 tasks merged:

  • ENG-2238 (contract + types) ✅
  • ENG-2239 (HarnessModuleBuilder, 18 unit tests) ✅
  • ENG-2240 (SandboxService.loadHarness + injection, 7 unit tests) ✅
  • ENG-2241 (graceful-degradation tests, 7 scenarios, A4 closed) ✅
  • ENG-2243 (this PR, 5 scenarios, A3 closed) ← merge to finish Phase 3

Brutal-review items A3 + A4 both closed. Phase 5 (mode selector) is unblocked to start.

Related

  • Test file: test/integration/agent/harness/isolation.test.ts
  • Loader pipeline under test: SandboxService.loadHarness (ENG-2240), HarnessModuleBuilder (ENG-2239), HarnessStore (ENG-2227 + ENG-2228)
  • Deep-freeze + strict-mode injection (the defensive primitives): HarnessModuleBuilder.wrapInvocation (ENG-2239)
  • Brutal-review item closed: A3 (dual-VM isolation against 5 attack vectors)
  • Brutal-review item closed by Phase 3 as a whole: A4 (graceful degradation, ENG-2241)
  • Task doc: features/autoharness-v2/tasks/phase_3/task_05-isolation-integration-test.md (research repo)
  • Previous task in stream: ENG-2241 (graceful-degradation unit tests)
  • Next: Phase 5 (mode selector + AgentLLMService session-start hook — first real consumer of loadHarness)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Claude finished @danhdoan's task in 3m 51s —— View job


PR Review: feat: [ENG-2243] AutoHarness V2 dual-VM isolation integration test

Tasks

  • Read PR context and understand request
  • Read the test file (test/integration/agent/harness/isolation.test.ts)
  • Read related source files (HarnessModuleBuilder, SandboxService, HarnessStore)
  • Analyze test correctness, security assertions, and code quality
  • Post inline review comments
  • Post overall review summary

Overall Assessment: 4.5 / 5

This is a high-quality security proof-of-work. The five tests are well-scoped, correctly target the A3 attack vectors, and the in-source documentation is better than most test suites. The analysis below covers correctness, security assertions, code quality, and a few minor improvements.


Correctness

The pipeline under test is correctly assembled in beforeEach:

  • FileKeyStorage(inMemory: true) avoids tmpdir lifecycle concerns correctly — isolation attacks don't touch the filesystem.
  • HarnessModuleBuilder + HarnessStore + SandboxService are wired through the real service setters, matching production wiring exactly.
  • makeStubFileSystem is required because SandboxService.buildToolsSDK gates on this.fileSystem !== undefined — without it, executeCode returns undefined for the SDK and expectSandboxHealthy fails with hasTools: false. The stub is the minimal correct fixture here.

Each attack is exercised end-to-end through the real wrapInvocation path (Object.freeze + strict mode + VM timeout + error normalisation), not a mock. The health check after each attack correctly validates the service is not corrupted state.


Security Assertion Quality

Attack Isolation assertion Specific enough?
1. Global pollution globalThis[KEY] === undefined
2. Closure leak error.name === 'TypeError' + /read.?only/i ✅ Realm-agnostic idiom is correct
3. Mutable parameter message.match(/curate\(\) failed/) ⚠️ See inline — could be tighter
4. Prototype pollution ({} as any)[KEY] === undefined
5. Stack-trace escape capturedThis === undefined + capturedArbitrary === undefined ✅ Dual-property design is deliberate and strong

The cross-realm instanceof gotcha in Attack 2 is handled correctly (error.name === 'TypeError' instead of instanceof TypeError). The reasoning is documented in-source.

Attack 5 correctly uses instanceof Error (not name-check), because that error is constructed by the wrapper in Node's outer realm — so the standard instanceof works as expected. The test correctly distinguishes when to use each idiom.


Minor Issues (see inline comments)

  1. Attack 3 expect.fail comment — says "expected TypeError" but the caught error is a plain Error (the wrapper normalises it). Misleading for readers who expect a realm-aware pattern like Attack 2. [Flagged inline]

  2. Attack 3 assertion weaknessmessage.match(/curate\(\) failed/) passes for any harness failure, not specifically a frozen-property write. Attack 2 goes further with /read.?only/i. Consider: /curate\(\) failed.*read.?only/i. [Flagged inline]

  3. if (!result.loaded) return guard — this exists purely for TypeScript type narrowing; Chai already throws above it. A one-line comment would prevent future readers from mistaking it for a soft safety valve. [Flagged inline]


What Works Well

  • Unique sentinel keys per attack (ATTACK_1_GLOBAL_KEY, ATTACK_4_PROTO_KEY) prevent cross-test contamination if any isolation invariant ever regresses — failures stay localised to the broken test.
  • afterEach defensive cleanup is belt-and-braces: if an attack ever succeeds, the pollution is cleaned before the next test in the full suite sees it.
  • expectSandboxHealthy design — the single deep.equal snapshot against {math: 4, hasTools: true} proves JS eval + ToolsSDK integrity in one shot without async gymnastics.
  • Test is not racy — zero time-dependent assertions. The 59ms runtime is expected.
  • Storage choice reasoning is sound — in-memory avoids the mkdtemp lifecycle pattern from Task 2.5 because isolation attacks don't touch the filesystem.

Out-of-Scope Observations (not blocking)

The following attack vectors are NOT covered here (intentionally, per A3 scope):

  • Function.prototype mutation inside the VM
  • Symbol.iterator manipulation
  • __proto__ direct assignment (obj.__proto__ = ...)

These would only affect the VM's own realm per V8's context isolation, so they are consistent omissions for a Phase 3 PR. They would be Phase 6+ concerns if the threat model expands.


Verdict: Merge-ready with optional nits. The two inline suggestions (Attack 3 message and expect.fail comment) are improvements but not blockers — the security invariants are correctly asserted even without them.

Comment thread test/integration/agent/harness/isolation.test.ts
Comment thread test/integration/agent/harness/isolation.test.ts Outdated
Comment thread test/integration/agent/harness/isolation.test.ts Outdated
Comment thread test/integration/agent/harness/isolation.test.ts
Comment thread test/integration/agent/harness/isolation.test.ts
Comment thread test/integration/agent/harness/isolation.test.ts
Phase 3 Task 3.5 — closes brutal-review item A3 with 5 integration
tests covering every cross-VM attack vector. Each test exercises the
full pipeline (real `HarnessStore` + real `HarnessModuleBuilder` +
real `SandboxService.loadHarness`) against a harness fixture
designed to breach the sandbox boundary; the test asserts the breach
is contained and the pipeline stays healthy.

1. **Global pollution** — harness `globalThis.X = 'touched'` stays
in the VM's private `globalThis`; Node's `globalThis[X]` remains
`undefined` (V8 per-context realm).

2. **Closure leak** — harness returns a closure that captures
`ctx.tools` and tries `stolen.curate = hijackFn` when called.
Deep-freeze at the invocation boundary (ENG-2239) + strict-mode
injection means the mutation throws a VM-realm `TypeError` with
message matching `/read.?only/i`.

3. **Mutable parameter** — harness writes `ctx.env.commandType =
'hacked'`. Deep-freeze throws; wrapper normalizes as `curate()
failed: ...`.

4. **Prototype pollution** — harness `Object.prototype.X = 'polluted'`
stays in the VM realm's prototype; Node's `({} as any).X` remains
`undefined`.

5. **Stack-trace escape** — harness throws error with attached
`capturedThis: globalThis` AND `capturedArbitrary: {secret: ...}`.
Wrapper constructs a fresh `new Error('curate() failed: ' + msg)`
— only `message` survives, all attached properties drop.

**Cross-realm `instanceof` (Attack 2)**: TypeErrors from
`vm.createContext`
are instances of the VM realm's TypeError class, not Node's outer
realm. `error instanceof TypeError` fails despite the error being
structurally a TypeError. Fix: check `error.name === 'TypeError'`
(string-valued, realm-agnostic) + `error.message` match.

**`capturedArbitrary` property (Attack 5)**: attack attaches two
properties to the thrown error — a known one (`capturedThis`) and an
arbitrary one (`capturedArbitrary`). Proves the wrapper strips
*all* attached properties by constructing a fresh Error, not just
the ones it explicitly knows about.

Per-attack `expectSandboxHealthy()` now asserts BOTH `2 + 2 === 4`
(JS intact) AND `typeof tools === 'object' && typeof tools.readFile
=== 'function'` (tools namespace wired). A single sandbox snapshot
proves "attack didn't destabilize execution OR tool injection."

Uses `FileKeyStorage({inMemory: true})` instead of the task doc's
suggested `mkdtemp` + disk. Attacks don't need real I/O; in-memory
storage means zero disk footprint, zero tmpdir collision surface, zero
cleanup logic.

Full suite: 6710 passing / 0 failing; 59ms for this file's 5 tests.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Claude finished @danhdoan's task in 2m 29s —— View job


PR Review: feat: [ENG-2243] AutoHarness V2 dual-VM isolation integration test

Tasks

  • Read PR context and understand request
  • Read the test file (test/integration/agent/harness/isolation.test.ts)
  • Read related source files (HarnessModuleBuilder, SandboxService, HarnessStore)
  • Analyze test correctness, security assertions, and code quality
  • Post overall review summary

Overall Assessment: 4.5 / 5

This is a high-quality security proof-of-work PR. The prior review's three nit suggestions are already incorporated in the current file — no outstanding items remain. Review below documents what was verified.


Prior Review Nits: Status

All three suggestions from the prior review cycle are confirmed present:

Nit Location Status
expect.fail comment accuracy line 265 'expected wrapped Error — ctx.env is frozen (TypeError inside VM)'
Attack 3 assertion tightened to include /read.?only/i line 267 .to.match(/curate\(\) failed.*read.?only/i)
TypeScript narrowing comment on if (!result.loaded) return guards lines 169, 204, 252, 319 // unreachable: Chai assertion above throws first — TypeScript narrowing only

Correctness

Pipeline wiring in beforeEach matches production exactly: FileKeyStorage(inMemory: true)HarnessStoreHarnessModuleBuilderSandboxService, with setFileSystem supplying the stub that buildToolsSDK gates on. Each attack exercises the full loadHarnesswrapInvocation path — no mocked isolation boundaries.

Template string interpolation for sentinel keys is verified correct:

  • Attack 1: globalThis.${ATTACK_1_GLOBAL_KEY} = 'touched' resolves to the literal string in the VM code, assigning to the VM's own globalThis
  • Attack 4: Object.prototype.${ATTACK_4_PROTO_KEY} = 'polluted' mutates the VM realm's Object.prototype, not Node's ✓

Security Assertion Quality

Attack Isolation assertion Verdict
1. Global pollution globalThis[KEY] === undefined
2. Closure leak error.name === 'TypeError' + /read.?only/i ✅ realm-agnostic idiom
3. Mutable parameter .match(/curate\(\) failed.*read.?only/i) ✅ proves root cause, not just any harness failure
4. Prototype pollution ({} as any)[KEY] === undefined
5. Stack-trace escape capturedThis === undefined + capturedArbitrary === undefined ✅ dual-property design

Cross-realm instanceof idioms are correctly applied throughout:

  • Attack 2 (line 226): expect(error).to.be.an('error') — Chai's .an('error') uses Object.prototype.toString.call(val) which is realm-agnostic, then name === 'TypeError' for the type check. This is correct.
  • Attack 5 (line 326): expect(error).to.be.instanceOf(Error) — correct here because the error is constructed by the wrapper (throw new Error(...)) in Node's outer realm, not inside the VM.

The distinction is documented correctly: VM-realm errors use error.name; wrapper-constructed errors use instanceof.


What Was Verified (and works)

  • expectSandboxHealthy() design: The single deep.equal({math: 4, hasTools: true}) snapshot proves JS eval is not corrupted AND tools.readFile is still accessible in one assertion without async gymnastics. The session 's1' used here is the same session the attack ran on, so health is confirmed for the post-attack state of the same service instance.
  • afterEach defensive cleanup: Both sentinel keys are cleaned up even if the attack succeeds — test-suite cross-contamination is prevented.
  • Unique sentinel keys per attack: ATTACK_1_GLOBAL_KEY and ATTACK_4_PROTO_KEY are distinct, so a leak from one attack doesn't silently mask or interfere with the other.
  • makeStubFileSystem double-cast (as unknown as IFileSystem): Acceptable in test stubs; justified by the in-source comment referencing the established pattern.
  • VALID_META at module level: Correct — the meta fixture is identical across all 5 attacks and factored out cleanly.
  • No source code changes: Scope boundary held.

Out-of-Scope Observation (not blocking)

The makeCtx() factory comment notes that the curate/readFile stubs "no attack actually calls a real tool." Worth noting this is strictly true today, but Attack 2 does capture ctx.tools as a closure variable. If a future attack adds a tool-call path, the stubs would need session-bound fixtures. The comment already calls this out, which is good.


Verdict: Merge-ready. The three nits from the prior review are incorporated, all five attack vectors are correctly asserted, and the inline documentation is better than the average security test. No new concerns.

The `waitForPruneToSettle` helper returned on the first two consecutive
identical counts with 2ms polling, which under slow CI could catch the
pre-prune state twice and declare "settled" before any work started.

Require 5 consecutive stable readings at 5ms intervals (25ms of no
change) with a 1s max bound — enough to ride out the initial async gap
without inflating test runtime.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Claude finished @danhdoan's task in 2m 55s —— View job


PR Review: feat: [ENG-2243] AutoHarness V2 dual-VM isolation integration test

Tasks

  • Read PR context and understand request
  • Read the test file (test/integration/agent/harness/isolation.test.ts)
  • Read related source files (HarnessModuleBuilder, SandboxService, HarnessStore)
  • Diff current branch against base (origin/proj/autoharness-v2)
  • Analyze test correctness, security assertions, and code quality
  • Post overall review summary

Overall Assessment: 4.5 / 5

This is the third pass over this PR. All three nits from the first review cycle are confirmed incorporated in the current file. No new source-code changes. The analysis below covers the current state of both changed files — isolation.test.ts (new, 341 lines) and file-query-log-store.test.ts (modified, +16/-4 lines).


Prior Nits: Confirmed Addressed

Nit Location Status
expect.fail comment accuracy (Attack 3) line 265 'expected wrapped Error — ctx.env is frozen (TypeError inside VM)'
Attack 3 assertion tightened to /read.?only/i line 267 .to.match(/curate\(\) failed.*read.?only/i)
TypeScript narrowing comment on if (!result.loaded) return lines 169, 204, 252, 319 // unreachable: Chai assertion above throws first — TypeScript narrowing only

Pipeline Wiring Verified

beforeEach wires the full production chain:

  • FileKeyStorage(inMemory: true)HarnessStoreHarnessModuleBuilderSandboxService
  • setHarnessConfig({enabled: true, ...}) satisfies the guard at SandboxService.loadHarness:269
  • setFileSystem(makeStubFileSystem(sb)) satisfies the this.fileSystem !== undefined gate in buildToolsSDK (line 534 of sandbox-service.ts) — without this, executeCode returns no ToolsSDK and expectSandboxHealthy fails with hasTools: false

Each attack goes through the full loadHarness → HarnessModuleBuilder.build → wrapInvocation path. No mocked isolation boundaries.


Security Assertion Quality

Attack Isolation assertion Verdict
1. Global pollution globalThis[KEY] === undefined after VM sets it ✅ V8 per-context globalThis isolation
2. Closure leak error.name === 'TypeError' + /read.?only/i ✅ Realm-agnostic idiom; VM-realm TypeError correctly handled
3. Mutable parameter .match(/curate\(\) failed.*read.?only/i) ✅ Proves root cause, not just any harness failure
4. Prototype pollution ({} as any)[KEY] === undefined after VM mutates Object.prototype ✅ V8 per-context prototype chain isolation
5. Stack-trace escape capturedThis === undefined + capturedArbitrary === undefined ✅ Dual-property design proves ALL attached properties are stripped

Cross-realm instanceof idioms are correct throughout:

  • Attacks 2/3: TypeError originates in code compiled inside vm.createContext → uses VM-realm TypeError.prototypeinstanceof TypeError fails silently → error.name === 'TypeError' is the correct realm-agnostic check
  • Attack 5: The error reaching outer catch is new Error(...) constructed by wrapInvocation in Node's outer realm → instanceof Error works correctly

One subtle implementation detail confirmed: In Attack 3, the harness async function attempts ctx.env.commandType = 'hacked' — this runs synchronously inside the async frame and rejects the returned Promise. wrapInvocation awaits via Promise.race, catches the rejection, then normalizes it. The normalizer does error instanceof Error ? error.message : String(error) — for VM-realm errors, instanceof Error returns false and falls through to String(error), which still includes the "read only" text from the TypeError message. The /curate\(\) failed.*read.?only/i regex holds in both cases.


expectSandboxHealthy Scope Note

In all 5 attacks, result.module.curate(makeCtx()) is called directly on the loaded module — not through service.executeCode. This means the sandbox for session 's1' is never created during the attack phase. expectSandboxHealthy always creates a fresh sandbox on first executeCode call. This is the correct design: the health check proves "after the attack, a new sandbox can be constructed and wired correctly" — if an attack corrupted sessionHarnessStates or any internal service state, buildHarnessNamespace or buildToolsSDK would fail here.


file-query-log-store.test.tswaitForPruneToSettle Change

The change upgrades the stability check from "2 consecutive identical readings" to "5 consecutive identical readings at 5ms intervals":

before: 50 iters × 2ms = 100ms max, 2-count stable
after:  200 iters × 5ms = 1000ms max, 5-count stable (25ms of no change)

Correct fix: The old 2-count check could declare "settled" before the async prune process even started — two consecutive pre-prune reads with the same count would satisfy it. The 5-count / 25ms window gives the OS scheduler time to start the prune I/O before the helper concludes. The stable counter correctly resets to 0 on any change, so the 5-count is "5 consecutive identical readings" not "5 out of the last N readings." This prevents false-positive settlement in slow CI environments.

No correctness risk: The maximum budget (1000ms) is well under the test-level timeout. The change adds latency to every pruning test but eliminates the flake class.


What Was NOT Changed (Scope Boundary Held)

  • No source files modified (zero diff in src/)
  • No new test helpers
  • No changes to evaluator, loader, or store
  • file-query-log-store.test.ts change is isolated to the polling helper — no test logic or assertions modified

Out-of-Scope Observations (not blocking)

The following attack vectors are not covered (consistent with A3 scope):

  • Function.prototype mutation inside VM
  • Symbol.iterator manipulation
  • __proto__ direct assignment

Per V8 context isolation, these would only affect the VM realm anyway. Phase 6+ concern if the threat model expands.


Verdict: Merge-ready. All three prior nits are confirmed addressed, all five isolation invariants are correctly asserted, the waitForPruneToSettle stabilization is a sound CI fix, and scope boundary was held (pure test PR, no source changes).

@danhdoan danhdoan merged commit 31aa397 into proj/autoharness-v2 Apr 21, 2026
6 checks passed
@danhdoan danhdoan deleted the feat/ENG-2243 branch April 22, 2026 00:23
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