Uh oh!
There was an error while loading. Please reload this page.
[APPS-2792] Add: runtime network/subprocess guard for local execution - #484
Conversation
🎉 All green!🧪 All tests passed 🔗 Commit SHA: 063d85e | Docs | View more details | Give us feedback! |
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.
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.
e48778e to
2ad1ce9Compare2ad1ce9 to
b69e5f2Comparedev-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.
d976f85 to
1900a78Compareb69e5f2 to
63539ebComparedev-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.
1900a78 to
a0bcc4fCompare63539eb to
1b11592Comparedev-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.
a0bcc4f to
dc33400CompareCloses 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>
…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.
dgram (raw UDP) has its own socket implementation and doesn't route through net.Socket.prototype.connect, so it isn't caught by this guard's patching. Unlike the existing native-addon gap, this one is pure JS and closeable the same way if it turns out to matter — call it out explicitly rather than leaving it undisclosed alongside the addon gap.
… found in review network-guard.ts's applyPatches/restorePatches and allowDepth are shared, module-level state with no per-execution identity. An abandoned (not cancelled) execution's runBlocked/runAllowed call can settle well after a newer execution has started its own — its late restore/decrement was acting on whatever the newer execution's own snapshot/depth happened to be, either prematurely unblocking the newer execution's active sandbox or driving allowDepth permanently negative (breaking every future $.Actions call in the process). Both runBlocked and runAllowed now capture a generation counter at entry and skip their own restore/decrement if a newer generation has since taken over; forceReset() bumps the generation so an abandoned call's eventual settlement is reliably recognized as stale. registerActionCatalogIfInstalled's registered callback also didn't wrap its call to the injected executeAction in runAllowed the way makeActionsProxy's raw $.Actions path does — a real action-catalog typed-wrapper call whose ExecuteAction implementation itself makes a network call was incorrectly rejected as blocked, since it runs from inside the customer function's still-active runBlocked scope.
1b11592 to
9d591ecComparetyffical
commented
Aug 21, 2026
There was a problem hiding this comment.
Pull request overview
Friend, this PR adds runtime restrictions for in-process local backend execution.
Changes:
- Adds process-wide network and subprocess guards.
- Exempts
$.Actionscalls 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
runAllowedcan run after its enclosing blocked scope has already been reset. In the existing abandoned-execution scenario, a late call through a captured$.Actionsproxy increments from zero, the guarded action rejects, and thisapplyPatches()then leaves the whole process blocked even though norunBlockedis active; the test'safterEach(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 enteringrunAllowed.
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.
Uh oh!
There was an error while loading. Please reload this page.
| // 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)); |
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
| function restorePatches(): void { | ||
| if (savedConnect) { | ||
| net.Socket.prototype.connect = savedConnect; | ||
| } | ||
| if (savedFetch) { | ||
| globalThis.fetch = savedFetch; | ||
| } | ||
| if (savedSpawn) { | ||
| child_process.spawn = savedSpawn; | ||
| } | ||
| if (savedExec) { | ||
| child_process.exec = savedExec; | ||
| } | ||
| if (savedExecSync) { | ||
| child_process.execSync = savedExecSync; | ||
| } | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:9d591ecafc
ℹ️ 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".
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…etwork guard execFile/execFileSync/fork weren't patched alongside spawn/exec/execSync, and restorePatches() left stale references in the saved* variables after consuming them, so an idle forceReset() could reinstall a snapshot from a completed cycle and clobber whatever a later caller had installed since. Also tried moving runBlocked to wrap loadModule itself, to close the gap where a module-level side effect runs before the guard is active. Reverted: it broke the real Vite dev-server integration test, since ssrLoadModule's own transform pipeline needs real network/fs access internally. Documented as an accepted residual gap instead, alongside the existing native-addon and dgram gaps.
… blocked spawnSync was missing from the patched subprocess API list alongside spawn/exec/execSync/execFile/execFileSync/fork. More significantly: runAllowed captured currentGeneration at entry, a counter that keeps monotonically advancing regardless of whether any runBlocked scope is actually active. An abandoned execution's $.Actions call that only reaches runAllowed after forceReset already fired (rather than being already in flight when it fires) would capture whatever generation number is current at that point — a number that, with no newer execution started yet, never changes again. Its own finally would then see that generation still "matches" and call applyPatches() with no corresponding runBlocked left alive to ever restorePatches() again, permanently blocking net/fetch/child_process for every execution afterward. Introduces activeGeneration, tracking which generation (if any) currently owns a live runBlocked scope, separately from the ever-incrementing currentGeneration counter. runAllowed now checks activeGeneration both at entry (treating a call with no live scope to belong to as an inert no-op) and at exit, closing the gap.
tyffical
commented
Aug 21, 2026
@codex review |
There was a problem hiding this comment.
💡 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)); |
There was a problem hiding this comment.
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 👍 / 👎.
| savedExecFileSync = child_process.execFileSync; | ||
| savedFork = child_process.fork; | ||
| net.Socket.prototype.connect = throwNetworkBlocked as typeof net.Socket.prototype.connect; |
There was a problem hiding this comment.
Block server-side socket creation too
When dependency code calls net.createServer().listen(...) inside a backend function, it never invokes Socket.prototype.connect, so this patch does not reject it (verified with the same prototype replacement on Node 20). The dependency can therefore open a real listening socket and leave it alive after the function returns, despite production running without network permission; patch the server/listen entry points as well as outbound connects.
Useful? React with 👍 / 👎.
| // (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)); |
| const abandonedAction = runAllowed( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| resolveAbandonedAction = resolve; | ||
| }), | ||
| ); | ||
| // Simulates the timeout handler abandoning this execution while | ||
| // the $.Actions call above is still in flight. | ||
| forceReset(); | ||
| // A newer execution starts, and its own legitimate $.Actions call | ||
| // must be correctly allowed through and re-blocked afterward. | ||
| const result = await runBlocked(async () => { | ||
| await runAllowed(async () => 'newer allowed call'); | ||
| await expect(fetch('https://example.com')).rejects.toThrow( | ||
| /Network access is not allowed/, | ||
| ); | ||
| return 'newer execution result'; | ||
| }); | ||
| expect(result).toBe('newer execution result'); | ||
| // The abandoned call's runAllowed finally now fires, well after | ||
| // being superseded — it must not touch allowDepth. | ||
| resolveAbandonedAction?.(); | ||
| await abandonedAction; |
runAllowed previously restored real net/fetch/child_process globally for its duration (a shared allowDepth counter), so a sibling call made concurrently by customer code — e.g. Promise.all([$.Actions.foo(...), fetch(url)]) — got a free pass for the entire window the legitimate $.Actions call was in flight, defeating the guard for a common concurrent-call pattern. Replaces the global toggle with an AsyncLocalStorage-scoped exemption: the patched functions now stay installed for the whole runBlocked duration and each checks whether its own specific call is running inside the async chain runAllowed started, rather than whether any runAllowed call is active anywhere. A sibling call outside that chain sees no store at all and stays blocked, regardless of what else is concurrently allowed.
Motivation
local-execution.tslives there and because this PR shares theLOCAL_EXECUTION_LOAD_SUFFIXmechanism [APPS-2792] Add: wire local execution into the real dev server #481 introduces (see below).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.local-execution.tscall-site change (markingloadModule's request withLOCAL_EXECUTION_LOAD_SUFFIXso the frontend RPC-proxy transform hook doesn't intercept it) and the same test-double extraction intomoduleResolverFor. Rebasing onto [APPS-2792] Add: wire local execution into the real dev server #481 let git drop this PR's own now-redundant copies of both commits outright (patch contents already upstream) instead of leaving a real merge conflict for whichever PR merged second.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 — alongsidelocal-execution.ts's own epoch-gatedpoisonActionCatalogRegistration()call in the same timer (from #480), since both are closing the same class of "abandoned execution left shared state pointing the wrong way" gap. 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: 30 passed ✅ VERIFIEDyarn test:unit packages/plugins/apps # Expected: Test Suites: 25 passed / Tests: 337 passed ✅ VERIFIEDyarn workspace @dd/apps-plugin run typecheck # 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.This module isn't independently reachable from a real
npm run devsession on its own — that requires #481. It was exercised as part of a real, combined manual QA pass across the full stack (see #481's QA Instructions): a real scaffolded app, running with the full stack merged locally, confirmed the network guard correctly blocks rawnet/fetchin a customer function while still letting a real$.Actionscall through — end-to-end, not just at the unit-test level.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