Skip to content

[APPS-2792] Add: runtime network/subprocess guard for local execution - #484

Draft
tyffical wants to merge 7 commits into
masterfrom
tiffany.trinh/apps-2792-runtime-network-guard
Draft

[APPS-2792] Add: runtime network/subprocess guard for local execution#484
tyffical wants to merge 7 commits into
masterfrom
tiffany.trinh/apps-2792-runtime-network-guard

Conversation

@tyffical

@tyffical tyffical commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions.
    • This is the runtime half of the sandboxing milestone described in the Kickoff doc.
  • Stacks on the wire-into-dev-server PR, itself stacked on the harden-local-execution PR.
    • Shares that PR's LOCAL_EXECUTION_LOAD_SUFFIX mechanism and moduleResolverFor test double.
  • The reject-Node-builtins PR rejects Node-builtin imports and bare fetch/XMLHttpRequest/WebSocket/EventSource references in the customer's own .backend.ts file.
    • It doesn't catch the same calls inside a third-party dependency (e.g. a Postgres or Redis client) that uses net/http/fetch internally — that static scan never inspects node_modules.
  • Local execution runs in-process (no Deno, no forked child — per the in-process local execution PR's design), so unlike production there's no OS-level boundary to fall back on for that gap.
    • Production's Deno sandbox never grants --allow-net under any code path (confirmed in wf-actions-worker's deno.ts); this PR closes the equivalent gap at the module level.

Architecture

  • network-guard.ts patches every module-level entry point a customer's code (or its dependencies) could use to reach the network directly:

    • Outbound: net.Socket.connect, fetch, dgram
    • Inbound: net.Server.listen, dgram.Socket.bind
    • Subprocess: child_process's spawn/exec family
    • Worker threads: worker_threads.Worker
    • DNS resolution: dns/dns.promises, all Resolver variants
  • Most of these are process-wide singletons, so installGuardedProperty installs a permanent Object.defineProperty getter/setter on each rather than sandboxing the customer's module — there's no process boundary to sandbox with.

  • A worker thread is the exception: its fresh V8 realm inherits none of this file's monkeypatches, so blocking Worker construction itself is the only enforceable boundary.

  • Whether a call is blocked is never a shared boolean or counter — it's read fresh, per call, from two AsyncLocalStorage instances scoped to the calling async chain:

runScriptLocally
      │
      ▼
┌────────────────────────────────────┐
│ runBlocked(fn)                     │  blockedContext.run(true, fn)
│ isCurrentlyBlocked() → true for    │
│ this async chain only              │
└───────────────┬─────────────────────┘
                │  customer's fn() runs
                ▼
  fn() calls $.Actions.a() and $.Actions.b() concurrently (Promise.all)
                │
      ┌─────────┴──────────┐
      ▼                    ▼
 runAllowed(a)         runAllowed(b)
 allowedContext.run(   allowedContext.run(
   true, a)              true, b)
      │                    │
      ▼                    ▼
┌────────────────────────────────────┐
│ isCurrentlyBlocked() → false       │
│ for EACH call's own async chain —  │
│ overlapping siblings never share   │
│ or contend over one flag           │
└──────┬───────────────────────┬─────┘
       │ a resolves            │ b resolves
       ▼                       ▼
 a's allowedContext        b's allowedContext
 chain ends — no effect    chain ends — no effect
 on b, still in flight     on a (already done)
                │
                ▼
        fn() returns — runBlocked's
        blockedContext chain ends
                │
                ▼
   Back outside any AsyncLocalStorage.run() —
   isCurrentlyBlocked() reads no store, false
  • Because each runAllowed(a)/runAllowed(b) call gets its own AsyncLocalStorage context, two concurrent $.Actions calls never contend over shared exemption state the way a ref-counter would — one call's exit can't prematurely re-block a sibling still in flight.
  • A hung customer function stays blocked fail-safe by construction: runScriptLocally's Promise.race([run(), timeout]) abandons rather than cancels the loser, so blockedContext.run(true, fn) never reaches its natural exit and that call stays blocked for as long as it runs.
    • forceReset() doesn't touch this — it only invalidates a separate epoch counter (execution-epoch.ts, shared with local-execution.ts's registration-poisoning and env-guard.ts's env scoping) that runAllowed's fast path uses to detect whether any runBlocked scope is active anywhere, so a new execution's runAllowed isn't mistaken for still running inside an old, abandoned scope.
    • The Jest afterEach in network-guard.test.ts calls forceReset() to reset shared epoch state between tests, not to un-patch anything — the getter/setter installation is permanent.

Changes

18 changes across 5 files
What changed File
Added hasActiveScope() and forceInvalidate() to EpochGuard, so runAllowed/forceReset can detect and invalidate an active runBlocked scope. execution-epoch.ts
New installGuardedProperty installs a permanent getter/setter on each guarded Node global, rebuilding the exposed wrapper object on every external write. network-guard.ts
New runBlocked(fn)/runAllowed(fn) scope block/allow state per async chain via AsyncLocalStorage, not a shared flag. network-guard.ts
New forceReset() invalidates the shared epoch counter so a new execution's runAllowed isn't mistaken for an old, abandoned scope. network-guard.ts
runScriptLocally wraps the customer's function call — including its result's assertJsonSerializable check — in runBlocked, not the preceding loadModule/registration calls. local-execution.ts
runScriptLocally's timeout handler now also calls forceReset(), so an abandoned execution's later runAllowed call is a no-op instead of a false exemption. local-execution.ts
makeActionsProxy and the action-catalog dispatcher JSON round-trip inputs via new serializeActionInputs before entering runAllowed, closing a toJSON()/getter exfiltration path. local-execution.ts
Extended the guard to dgram (UDP) and the native WebSocket global, both previously unguarded. network-guard.ts
Guarded inbound listener entry points (net.Server.prototype.listen, dgram.Socket.prototype.bind) via the renamed generic guardNetworkMethod. network-guard.ts, network-guard.test.ts
Blocked worker_threads.Worker construction via a Proxy construct trap, mirroring guardWebSocket's shape. network-guard.ts, network-guard.test.ts
Guarded all four DNS resolver surfaces (dns, dns.promises, dns.Resolver.prototype, dns.promises.Resolver.prototype), rejecting rather than throwing on the promise-returning ones. network-guard.ts, network-guard.test.ts
Each guard now captures its own snapshot of the real delegate at build time, instead of a variable shared across all guards on the same property. network-guard.ts
Unit tests cover every guarded target in both directions, including concurrency and abandoned-scope epoch handling. network-guard.test.ts
Integration tests confirm the guard is wired into executeScriptLocally: raw network/subprocess calls are rejected, and concurrent $.Actions calls still succeed. local-execution.test.ts
exec/execFile's guarded wrappers re-attach a util.promisify.custom implementation resolving {stdout, stderr}, matching Node's native contract. network-guard.ts, network-guard.test.ts
installGuardedProperty's restore path tracks which real value was active when each guard was built, so the "capture original, mock, restore" idiom doesn't corrupt it. network-guard.ts, network-guard.test.ts
installGuardedProperty handles a non-object makeGuard result (e.g. guardWebSocket returning undefined) without throwing. network-guard.ts, network-guard.test.ts
Also guards ChildProcess.prototype.spawn directly, since the standalone spawn/exec/etc. functions are thin wrappers a dependency could call through to bypass them. network-guard.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/network-guard.test.ts
# Expected: Test Suites: 1 passed / Tests: 38 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 1 passed / Tests: 88 passed ✅ VERIFIED
yarn build:all && yarn test:unit
# Expected: Test Suites: 91 passed / Tests: 2239 passed, 1 skipped ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint 'packages/plugins/apps/**/*.ts' packages/tests/src/_jest/helpers/mocks.ts --quiet
# Expected: no output, clean exit ✅ VERIFIED
  • Coverage note: this repo's Jest collectCoverageFrom CLI flag doesn't produce a usable per-file report for either new/changed file in this environment (pre-existing tooling quirk — the coverage table only ever lists _jest helper files regardless of the glob passed). Manually verified every branch in network-guard.ts is exercised by at least one test.
  • This guard has no standalone HTTP surface of its own — it's exercised end-to-end via the wire-into-dev-server PR's dev server; see that PR's QA Instructions for the request-level test.
    • Also exercised as part of a combined manual QA pass across the full stack: a scaffolded app, running with the full stack merged locally, confirmed the network guard blocks raw net/fetch in a customer function while still letting a real $.Actions call through, end-to-end.
  • The guard rebuilds its exposed wrapper object on every external write to the property it patches, rather than exposing one frozen object for the whole process's lifetime, so an independent library patching the same global (e.g. a test's own fetch interceptor) never collides with a patch marker an earlier session left on it.
    • Verified against the repo's full apps test suite (yarn workspace @dd/tests run test:unit --testPathPatterns="apps"), not just this file in isolation — see the QA guide's "Testing a new process-wide guard" section for why that distinction matters for any guard touching a real Node global.

Blast Radius

  • No behavior change for any currently-shipping code path — this stack isn't released yet.
  • Scoped precisely to the async chain of a local execution's customer-function call, via AsyncLocalStorage; the dev server's own network use (before/after that call, and anything unrelated to local-execution.ts) is never touched.
  • An abandoned (timed-out, not cancelled) hung customer function stays blocked for as long as it keeps running — there's no reset that restores its network access early, so the fail-safe direction is preserved even for a zombie continuation.
  • Risk: low. Additive, defense-in-depth only — closes a gap that only matters for local-dev-loop safety/prod-parity, not a new production security boundary (production's Deno sandbox is unaffected and remains the real boundary).

Out of Scope / Follow-ups

2 items deferred
Item Status Next step
Native addon bypassing Node's JS-level net stack entirely Accepted residual gap Narrower and rarer than the pure-JS case this closes (most native modules are for CPU-bound work, not networking) — not worth the false-positive risk of blocking native addon loading outright
dns.lookup interception Out of scope Low realistic benefit for this threat model (dev-loop safety, not defending against deliberate DNS-tunneling exfiltration) — would risk breaking legitimate hostname validation for no real gain

Documentation

@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Aug 8, 2026

Copy link
Copy Markdown

Tests

All CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: e93c1e7 | Docs | View more details | Give us feedback!

tyffical added a commit that referenced this pull request Aug 10, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
tyffical added a commit that referenced this pull request Aug 20, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from e48778e to 2ad1ce9 Compare August 20, 2026 22:19
@tyffical
tyffical changed the base branch from tiffany.trinh/apps-2792-harden-local-execution-v2 to tiffany.trinh/apps-2792-wire-into-dev-server August 20, 2026 22:25
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 2ad1ce9 to b69e5f2 Compare August 20, 2026 22:28
tyffical added a commit that referenced this pull request Aug 20, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from b69e5f2 to 63539eb Compare August 20, 2026 23:31
tyffical added a commit that referenced this pull request Aug 20, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 63539eb to 1b11592 Compare August 20, 2026 23:40
tyffical added a commit that referenced this pull request Aug 21, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 1b11592 to 9d591ec Compare August 21, 2026 03:54
@tyffical
tyffical requested a balanced review from Copilot August 21, 2026 16:24
@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Friend, this PR adds runtime restrictions for in-process local backend execution.

Changes:

  • Adds process-wide network and subprocess guards.
  • Exempts $.Actions calls and resets guards after timeouts.
  • Adds unit and integration coverage for guard behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
network-guard.ts Implements blocking, exemptions, and reset logic.
network-guard.test.ts Tests guard state and concurrency.
local-execution.ts Integrates guards into local execution.
local-execution.test.ts Tests execution-path guard behavior.
Suppressed comments (1)

packages/plugins/apps/src/vite/network-guard.ts:165

  • runAllowed can run after its enclosing blocked scope has already been reset. In the existing abandoned-execution scenario, a late call through a captured $.Actions proxy increments from zero, the guarded action rejects, and this applyPatches() then leaves the whole process blocked even though no runBlocked is active; the test's afterEach(forceReset) masks the leak. Track whether this call actually entered from an active blocked scope and only reapply in that case, or perform the abandoned check before entering runAllowed.
        if (currentGeneration === myGeneration) {
            allowDepth -= 1;
            if (allowDepth === 0) {
                applyPatches();
            }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated
Comment on lines +428 to +433
// Blocks net/fetch/child_process for the duration of the customer's
// function call only — loadModule and the registration calls above
// (both Vite's own transform pipeline, no network) run unguarded.
// $.Actions calls made from inside fn are exempted via `runAllowed`
// in `makeActionsProxy`. See network-guard.ts.
const result = await runBlocked(() => fn(...args));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried fixing this by moving runBlocked to wrap loadModule itself, but reverted it — Vite's real ssrLoadModule pipeline needs genuine network/fs access internally to transform and resolve the customer's module, and blocking that broke the real dev-server integration test outright (not just theoretical: a real @datadog/apps-backend import through a real Vite server started returning 500). Documented as an accepted residual gap in network-guard.ts's own doc comment, alongside the existing native-addon and dgram gaps, rather than engineered around further for now. Leaving unresolved to keep it tracked.

Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated
Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b36f55e0bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// (both Vite's own transform pipeline, no network) run unguarded.
// $.Actions calls made from inside fn are exempted via `runAllowed`
// in `makeActionsProxy`. See network-guard.ts.
const result = await runBlocked(() => fn(...args));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip guard entry after an execution already timed out

When the timeout fires while loadModule(...) is still pending, forceReset() clears the guard and releases the queue, but the abandoned run() continues and enters this runBlocked call once loading completes. If a newer execution is already blocked, the stale call overwrites its saved snapshots and generation; when either call finishes, the process can be left permanently patched, and a stale function that hangs leaves the same result. Check abandoned before invoking the function/entering a new guard scope.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same finding as the copilot review comment on this line — see my reply there. Traced through carefully and it doesn't currently reproduce; added a regression test proving it (2c6aa37d).

Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

// (both Vite's own transform pipeline, no network) run unguarded.
// $.Actions calls made from inside fn are exempted via `runAllowed`
// in `makeActionsProxy`. See network-guard.ts.
const result = await runBlocked(() => fn(...args));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traced through carefully — this doesn't currently reproduce. The if (abandoned) throw check immediately before entering runWithScopedEnv/runBlocked runs synchronously with no await in between, so there's no window for the timeout's setTimeout callback to interleave and flip abandoned to true after the check but before the guards are entered. Added a regression test (2c6aa37d) that specifically simulates this: A's own loadModule for its main function body resolves late, after B (a newer execution) has already started and is still running its own body inside runBlocked/runWithScopedEnv — A correctly bails via the abandoned check without ever touching the guards, leaving B's state untouched.

Comment thread packages/plugins/apps/src/vite/network-guard.test.ts
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 4fd59b3 to cb79658 Compare August 26, 2026 04:32
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from cb79658 to affa16b Compare August 26, 2026 16:15
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from affa16b to fbad040 Compare August 26, 2026 17:31
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from fbad040 to 085afa1 Compare August 26, 2026 18:09
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 085afa1 to 64e9169 Compare August 27, 2026 06:05
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from 64e9169 to aff9ee7 Compare August 27, 2026 06:15
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from aff9ee7 to a2525b5 Compare August 27, 2026 17:08
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-runtime-network-guard branch from a2525b5 to 2e2c8eb Compare August 27, 2026 18:10
@tyffical

tyffical commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T18:10:28.873996Z 4d1607a Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d1607af92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/plugins/apps/src/vite/network-guard.ts Outdated
Comment thread packages/plugins/apps/src/vite/network-guard.ts
Comment thread packages/plugins/apps/src/vite/network-guard.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Comment thread packages/plugins/apps/src/vite/network-guard.ts
Comment thread packages/plugins/apps/src/vite/network-guard.ts
Comment thread packages/plugins/apps/src/vite/network-guard.ts
Comment thread packages/plugins/apps/src/vite/network-guard.test.ts Outdated
Comment thread packages/plugins/apps/src/vite/network-guard.ts
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
tyffical and others added 7 commits September 2, 2026 18:12
Runs the customer function's own call (not loadModule or the action-catalog/
apps-backend registrations, which need real network/fs access) with global
net/fetch/child_process access blocked, hardened against every bypass a
review pass found: a malicious result's toJSON()/getter still running under
the block, and $.Actions/action-catalog calls made from inside the function
exempted via runAllowed so the guard doesn't also block trusted API calls.
…ppers

Node's native exec/execFile carry a util.promisify.custom implementation
resolving {stdout, stderr}; wrapping them into a plain function silently
drops that symbol, so promisify() falls back to its generic single-value
behavior instead. This broke @dd/tools' own execute() helper — used by,
among other things, the injection plugin's test suite to run built bundles
and assert on their captured stdout — whenever it ran in the same Jest
worker as a test that imports network-guard.ts, since the property patch
is process-wide. Bisected via git bisect against a CI failure entirely
outside this package (packages/plugins/injection), confirmed by reproducing
the exact symptom in isolation before landing the fix.

Reimplements the {stdout, stderr} contract through the guarded callback
path rather than reusing Node's original custom implementation, which
would call straight into the native binding and bypass the block guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither goes through net.Socket or fetch, so both fully escaped the
existing guard — a backend function could exfiltrate data over UDP or
open a raw WebSocket undetected. Also drops a comment's dangling
cross-reference to env-guard.ts, which doesn't exist on this branch.
…f, sync ESM child_process bindings

installGuardedProperty's setter treated any incoming value as a new real
implementation, including a previously-read guard object handed back by the
common "capture original, mock, restore" pattern -- corrupting real into a
guard that calls itself forever on the next unblocked invocation. Now tracks
which real value was active when each guard was built, and restores that
value when a tracked guard is written back instead of the guard itself.

Also calls syncBuiltinESMExports() after installing the child_process guards,
since Node keeps ESM named bindings (e.g. `import { spawn } from
'node:child_process'`) as separate references that stay bound to the
original native functions otherwise -- verified with a standalone
`node --input-type=module` script since Jest's CJS transform can't reproduce
the real ESM-binding divergence this fixes.

Corrected a misleading comment: forceReset() invalidates the epoch so a
stale runAllowed call becomes a no-op, it does not restore real network
access -- the block itself stays enforced via AsyncLocalStorage regardless.
…DNS resolvers

net.Server.prototype.listen and dgram.Socket.prototype.bind were unguarded
inbound entry points, letting a dependency open a real listening socket
during blocked execution; both are now guarded the same way the existing
outbound dgram send/connect methods are, via the shared guardNetworkMethod
wrapper (renamed from guardDgramMethod, since it was always generic).

worker_threads.Worker construction is blocked via a Proxy construct trap
mirroring guardWebSocket's shape -- a Worker gets a fresh V8 realm that
doesn't inherit any of this file's monkeypatches or the AsyncLocalStorage
block context, so guarding its internals from the parent thread isn't
possible; blocking construction itself is the only enforceable boundary.

dns.resolve*()/dns.promises.resolve*() (and their Resolver-class
equivalents, across all 4 surfaces: dns, dns.promises, dns.Resolver.prototype,
dns.promises.Resolver.prototype) bypass net.Socket/dgram.Socket entirely via
Node's native c-ares channel, so neither existing guard ever observed those
calls. The promise-returning surfaces (dns.promises.*, dns.promises.Resolver
.prototype.*) reject rather than throw synchronously when blocked, matching
guardFetch's existing reject-not-throw contract and the native dns.promises
API shape -- a caller chaining .catch() outside an async wrapper would
otherwise see an uncaught synchronous throw instead of a normal rejection.
dns.lookup remains intentionally unguarded, per the existing Out of Scope
decision.
…a shared mutable variable

The direct-reassignment restore idiom (const original = x; x = mock; ...;
x = original) was already handled by tracking which real value was active
when each guard was built. A wrapper closure over the previous guard
(x = (...a) => previous(...a)) is a distinct pattern some mocking
approaches use instead, and it still recursed: the previous guard's own
getReal() read the same shared mutable variable the new guard had just
set to the wrapper, so unblocked calls looped between the two guards
until the stack overflowed. Each guard now closes over its own snapshot
of the real delegate taken at build time, so a stale guard reference
always resolves to what was real when it was built, regardless of what
the property currently holds.
…e essential WHY

Several doc comments and single-line // comments in this stack had grown to
280-1100+ characters or 8+ lines by stacking every contributing justification
instead of keeping the one that matters. Also extracts a repeated
`$.Actions.${pathParts.join('.')}` computation in makeActionsProxy's apply
trap into a single local, since it was now computed three times.
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