[APPS-2792] Add: runtime network/subprocess guard for local execution - #484
Draft
tyffical wants to merge 5 commits into
Draft
Conversation
Closes a gap the build-time checks in ast-parsing/ can't reach: those only scan the customer's own .backend.ts file, so a third-party dependency's own net/http/fetch usage (e.g. a Postgres/Redis client) was invisible to them, and nothing else stopped it once local execution moved in-process (no Deno, no process boundary). Blocks net.Socket.prototype.connect, fetch, and child_process's spawn/exec/execSync for the duration of a local execution, exempting only the internal $.Actions call via a ref-counted allow scope (so concurrent $.Actions calls within a single execution don't fight over re-blocking). Co-Authored-By: Claude <noreply@anthropic.com>
🎉 All green!🧪 All tests passed 🔗 Commit SHA: 7d6f536 | Docs | Datadog PR Page | Give us feedback! |
…xecutions Promise.race in runScriptLocally abandons whichever of run()/timeout loses without cancelling it. A hung customer function (its promise never settling) meant runBlocked's own finally never ran, leaving net.Socket.connect/fetch/child_process patched to throw for the rest of the process — poisoning every later local execution, and in CI, leaking into unrelated test files that happened to run afterward in the same Jest worker (e.g. rollupConfig.test.ts's real esbuild spawn). forceReset() is a hard backstop independent of runBlocked/runAllowed's own try/finally: it unconditionally restores the real functions and zeroes the ref-count. runScriptLocally calls it directly from the timeout timer, and network-guard.test.ts/local-execution.test.ts now call it in an afterEach regardless of test outcome, since these are real process-wide Node singletons, not per-test-file sandboxed state.
…ocally network-guard.test.ts already proves runAllowed's ref-counting at the unit level, calling it directly. Adds the same proof through the real path a customer's code takes: executeScriptLocally's Promise.all of two $.Actions calls, through makeActionsProxy's apply trap, with the mocked ExecuteAction making its own real fetch call to stand in for the network call the dev server's own implementation makes — network must stay allowed for the slower call the entire time the faster one is finishing and re-blocking. Also adds a regression test for the timeout/forceReset fix, and the same afterEach safety net as network-guard.test.ts.
local-execution.test.ts and dev-server.test.ts (build-plugins#481) 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 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.
…function body server.ssrLoadModule(func.absolutePath) goes through the same transform hook (vite/index.ts) that rewrites *.backend.ts into the client-side RPC-proxy stub — so local execution's "real" import can actually still be the proxy stub, which crashes since globalThis.DD_APPS_RUNTIME doesn't exist server-side. Every existing test here mocks loadModule directly, so none of them exercise the real transform pipeline and would catch this. Append the same query-suffix marker introduced in #481 (matching Vite's own ?raw/?url convention) so the shared transform hook can recognize this specific request and skip proxy generation for it. The transform-hook side of this fix lives in #481, since that's where local execution is actually wired to a real, plugin-registered dev server — this PR only needs its own call site and mocks to stay consistent with that contract so the two branches reconcile cleanly whichever merges first.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
local-execution.tslives.fetch/XMLHttpRequest/WebSocket/EventSourcereferences — but only in the customer's own.backend.tsfile. A third-party dependency (e.g. a Postgres or Redis client) that itself callsnet/http/fetchinternally is invisible to that static scan, since it never inspectsnode_modules.wf-actions-worker'sdeno.ts: production's Deno sandbox never grants--allow-net, under any code path — this PR closes the equivalent gap at the module level for local execution.Architecture
net.Socket.prototype.connect,globalThis.fetch, andchild_process'sspawn/exec/execSyncare real, process-wide singletons —network-guard.tsmonkey-patches them directly rather than sandboxing the customer's module, since there's no process boundary to sandbox with. That makes the guard a single shared piece of mutable state (allowDepth+ the saved originals) threaded through one execution's lifetime:The ref-count (
allowDepth), not a boolean, is what makes the overlap safe: two concurrent$.Actionscalls each bump it on entry and drop it on exit, and the guard only re-blocks once the last one exits — a boolean would re-block the instant the faster of two overlapping calls finished, breaking the slower one mid-flight.runBlocked/runAllowed's owntry/finallyonly unwinds whenfnactually settles.runScriptLocally's timeout wraps the whole thing inPromise.race([run(), timeout]), which abandons rather than cancels the loser — a customer function that never resolves meansrun()(and therunBlockedinside it) never reaches itsfinally, so without a separate backstop the block would stay applied for the rest of the process once the timeout fires.forceReset()is that backstop: called directly from the timer callback (unconditionally restoring the real functions and zeroingallowDepth) the moment the timeout fires, independently of whether the abandonedrun()ever settles. The same function is used as a JestafterEachinnetwork-guard.test.ts/local-execution.test.ts, for the identical reason at the test level — these are real Node singletons, not per-test-file sandboxed state, so a test that leaves them patched leaks into every test that runs after it in the same Jest worker, including unrelated test files.Changes
runBlocked(fn): monkey-patchesnet.Socket.prototype.connect,fetch, andchild_process'sspawn/exec/execSyncto throw/reject for the duration offn, restoring the real implementations in afinallyregardless of howfncompletes.runAllowed(fn): temporarily restores real network access for the duration offn, ref-counted (not a boolean) so two$.Actionscalls overlapping within a single execution (e.g. inside aPromise.all) don't re-block network on each other mid-flight.forceReset(): unconditionally restores the real functions and zeroesallowDepth, independent ofrunBlocked/runAllowed's ownfinally— the backstop for afnthat's abandoned (timeout) or a test that fails to clean up after itself.runScriptLocallynow wraps the customer's function call (only — not theloadModule/registration calls before it, which need no network) inrunBlocked, and callsforceReset()from the timeout timer itself so an abandoned, still-running hung function can't leave network/subprocess access blocked for the rest of the process.makeActionsProxy'sapplytrap now wraps itsexecuteActioncall inrunAllowed— the one sanctioned network path, exempted from the block.runBlockedcalls, nestedrunAllowedexemption, concurrent-overlap ref-counting, re-block-on-throw). A JestafterEachcallsforceReset()unconditionally as a hard safety net, independent of any test's own cleanup.executeScriptLocally: a customer function using rawnet/fetch/child_processis rejected; a real$.Actionscall still succeeds; network is restored after the execution finishes, including after a timeout abandons a hung function; two real$.Actionscalls made concurrently viaPromise.allkeep network allowed through the entire overlap, exercised through the realexecuteScriptLocally→makeActionsProxypath (not just the unit-levelrunAllowed). SameafterEachsafety net as above.QA Instructions
yarn test:unit packages/plugins/apps/src/vite/network-guard.test.ts # Expected: Test Suites: 1 passed / Tests: 11 passed ✅ VERIFIEDyarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts # Expected: Test Suites: 1 passed / Tests: 27 passed ✅ VERIFIEDyarn workspace @dd/apps-plugin run typecheck # Expected: no output, clean exit ✅ VERIFIEDnpx eslint packages/plugins/apps/src/vite/network-guard.ts packages/plugins/apps/src/vite/network-guard.test.ts packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts --quiet # Expected: no output, clean exit ✅ VERIFIEDCoverage note: this repo's Jest
collectCoverageFromCLI flag didn't produce a usable per-file report for either new/changed file in this environment (pre-existing tooling quirk, not introduced by this change — the coverage table only ever listed_jesthelper files regardless of the glob passed). Manually verified every branch innetwork-guard.tsis exercised by at least one test.No manual local/staging QA beyond the automated tests above: this module still isn't reachable from a real
npm run devsession (that's #481, already merged into this stack's ancestry but not yet released).Blast Radius
local-execution.ts) is never touched.forceReset()on timeout narrows, rather than eliminates, an existing gap: the abandoned (not cancelled) hung function keeps running with real network access restored early rather than staying blocked forever — bounded to that one already-abandoned execution, versus the alternative of leaving every future execution in the same dev server process permanently blocked until restart.Out of Scope / Follow-ups
netstack entirelydns.lookupinterceptionAbortSignalthreaded through the customer's own function, which we don't control) or re-architecting local execution onto a worker thread that can be killed outright — bigger change than this PR's scopeDocumentation