Skip to content

[APPS-2792] Add: reject Node built-in imports in backend files - #476

Draft
tyffical wants to merge 6 commits into
masterfrom
tiffany.trinh/apps-2792-sandboxing-import-restriction
Draft

[APPS-2792] Add: reject Node built-in imports in backend files#476
tyffical wants to merge 6 commits into
masterfrom
tiffany.trinh/apps-2792-sandboxing-import-restriction

Conversation

@tyffical

@tyffical tyffical commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions. Under today's v1 runtime, backend functions get no raw network access at all (not even fetch) — everything must go through an Action Platform action ($.Actions or an @datadog/action-catalog typed wrapper).
  • Static imports of Node built-ins (fs, child_process, net, etc.) in .backend.ts files are now rejected at build time, so an author gets immediate feedback instead of code that silently breaks once local Node execution lands.
  • Network-capable globals (fetch, XMLHttpRequest, WebSocket, EventSource) need a separate check, since they're bare globals rather than imports.
    • Closes a real trap: fetch works fine during local dev today but fails once published, since production's sandbox blocks it. Node's own global alias reaches the same globals and is treated identically.
  • crypto/Intl behave identically enough to work in both runtimes, but aren't guaranteed identical (RNG implementation, bundled ICU data) — part of the RFC's prod-parity divergence list.
    • These only warn, pointing authors at npm run dev:verify's real cloud round trip as the actual parity gate.
  • A complementary effort (web-ui#340206) steers AI-generated code away from fetch in the first place. That reduces how often it's written, but only this build-time check guarantees it never ships — both layers exist for a reason.
  • v1-specific: backend functions' planned v2 (Terrapin-based) sandbox will lift this restriction. Only legacy (pre-v2) apps need it.
  • These are the design doc's two "Layer 2" static defenses; the companion item (ambient TypeScript globals for $ that omit Node-specific types) is deferred — see Out of Scope.

Architecture

Three checks run in the Vite transform hook right after this.parse(code); two of them (the reject-style checks for restricted globals) share one AST traversal so a bypass fix lands once, not twice. A nested plugin re-runs all three against the whole backend module graph, not just the entry file.

.backend.ts (or an app-local module it imports)
     │
     ▼
this.parse(code) → AST
     │
     ├──▶ rejectNodeBuiltinImports     throws on a Node built-in
     │                                 (static or dynamic import)
     │
     ├──▶ rejectRestrictedGlobals ──┐  throws on an unshadowed
     │                              │  fetch/XHR/WebSocket/EventSource
     │                              ▼
     │                       forEachAmbientGlobalAccess
     │                       (bare ref, globalThis/global-qualified,
     │                        destructure, const-alias chains,
     │                        template-literal computed keys)
     │                              ▲
     └──▶ warnAboutDivergentGlobals ┘  warns (never throws) on
                                       crypto/Intl, same traversal

createBackendStaticChecksPlugin (nested Vite plugin)
  re-runs all three checks against every app-local module the
  backend build resolves — not just the .backend.ts entry the
  outer transform hook sees — reusing the connection-ID collector's
  already-parsed AST/scope analysis instead of re-parsing.

Changes

What changed File
Added rejectNodeBuiltinImports, which walks a .backend.ts file's static ImportDeclarations and throws if any source is a Node built-in (via node: prefix or Node's own builtinModules list). reject-node-builtin-imports.ts
Also rejects a literal dynamic import('node:fs'), which Rollup represents as an ImportExpression rather than a top-level ImportDeclaration. reject-node-builtin-imports.ts
Corrected its doc comment and error message, which previously pointed to fetch-based/isomorphic APIs as the allowed escape hatch — no longer accurate now that fetch itself is blocked too. reject-node-builtin-imports.ts
Added rejectRestrictedGlobals, an eslint-scope-based check that throws on any unshadowed reference to fetch/XMLHttpRequest/WebSocket/EventSource — i.e. one that doesn't resolve to a local declaration or import of the same name, so it falls through to the real ambient global. reject-restricted-globals.ts
It also treats Node's global alias the same as globalThis, in both qualified-access forms (global.fetch, destructuring off global). reject-restricted-globals.ts
Added warnAboutDivergentGlobals, which warns (never rejects) on crypto/Intl references — bare, globalThis-qualified, or destructured — deduped once per distinct global per file. warn-divergent-globals.ts
It also fires on a destructuring assignment (not just a declaration) and on a rest-destructure, matching coverage rejectRestrictedGlobals already had; its per-file dedup cache is now bounded so a long dev-server session can't grow it forever. warn-divergent-globals.ts
Extracted forEachAmbientGlobalAccess, a shared traversal for every syntactic form that reaches globalThis/global (bare reference, qualified member access, destructuring), used by both reject/warn checks so a bypass fix lands once instead of drifting between two hand-mirrored copies. ambient-global-access.ts
It now also resolves a computed member/destructure key written as a no-substitution template literal (globalThis[\`fetch\`]). ambient-global-access.ts
It now also follows a const alias of globalThis/global, including a chain of aliases (e.g. const x = globalThis; const y = x;), so the ambient-global identity is still recognized after being reassigned to a new name. ambient-global-access.ts
Wired all checks into the Vite transform hook, right after this.parse(code) and before export extraction. vite/index.ts
Added createBackendStaticChecksPlugin, a nested Vite plugin that re-runs all three static checks against every app-local module the backend build resolves — not just the .backend.ts entry — so an imported helper can't ship an undetected violation. backend-static-checks-plugin.ts
Wired into both the production build and the dev server's own bundling path, and now reuses the connection-ID collector's already-parsed AST/scope analysis instead of parsing a module a second time. backend-static-checks-plugin.ts, backend-connection-id-collector.ts
Added unit tests covering allowed imports (relative, scoped, ordinary npm packages), rejected imports (node:fs, bare fs, child_process, net, fs/promises), and edge cases (type-only imports, non-import statements). reject-node-builtin-imports.test.ts
Added unit tests covering rejected global references (bare fetch() calls, referencing fetch without calling it, new XMLHttpRequest()/WebSocket()/EventSource()) and allowed cases (an imported action-catalog function, a locally-declared function/parameter named fetch — shadowing-safe). reject-restricted-globals.test.ts
Added an end-to-end test that runs a real .backend.ts file with a node:fs import through the actual transform handler (using rollup's real parseAst, not a hand-built AST) to confirm the rejection fires through the genuine pipeline. vite/index.test.ts

QA Instructions

Build the plugin and link it into a scratch Vite project, then confirm a backend file importing a Node built-in — or referencing fetch — is rejected while an ordinary backend file still transforms correctly.

# 1. Build and link the plugin from this branch
cd ~/dd/build-plugins/packages/published/vite-plugin
yarn build
npm link

# 2. Scaffold a throwaway consumer project
mkdir -p ~/import-restriction-qa/src && cd ~/import-restriction-qa
cat > package.json <<'EOF'
{ "name": "import-restriction-qa", "private": true, "type": "module", "devDependencies": { "vite": "^5.0.0" } }
EOF
cat > vite.config.ts <<'EOF'
import { datadogVitePlugin } from '@datadog/vite-plugin/dist/src';
import { defineConfig } from 'vite';
export default defineConfig({
    plugins: [datadogVitePlugin({ apps: { identifier: 'qa-app-id', name: 'import-restriction-qa', dryRun: true } })],
});
EOF
cat > src/badImport.backend.ts <<'EOF'
import fs from 'node:fs';
export function readSecret() { return fs.readFileSync('/etc/passwd', 'utf8'); }
EOF
cat > src/badFetch.backend.ts <<'EOF'
export async function callExternal() { return fetch('https://example.com'); }
EOF
cat > src/goodImport.backend.ts <<'EOF'
export function doubleNumber(input: number) { return input * 2; }
EOF
cat > src/aliasedFetch.backend.ts <<'EOF'
export async function callExternal() { const g = globalThis; return g.fetch('https://example.com'); }
EOF
npm install && npm link @datadog/vite-plugin

# 3. Confirm the bad Node-builtin import is rejected with a clear error
npx vite --port 5199 --strictPort &
sleep 3
curl -s http://localhost:5199/src/badImport.backend.ts | grep -o 'Importing Node built-in module.*not supported in backend function code'
# Expected: Importing Node built-in module "node:fs" is not supported in backend function code ✅ VERIFIED
kill %1

# 4. Confirm the bad fetch reference is rejected with a clear error
npx vite --port 5197 --strictPort &
sleep 3
curl -s http://localhost:5197/src/badFetch.backend.ts | grep -o 'Using "fetch" is not supported in backend function code'
# Expected: Using "fetch" is not supported in backend function code ✅ VERIFIED
kill %1

# 5. Confirm an ordinary backend file still transforms into a working proxy
npx vite --port 5198 --strictPort &
sleep 3
curl -s http://localhost:5198/src/goodImport.backend.ts
# Expected: export async function doubleNumber(...args) { return globalThis.DD_APPS_RUNTIME.executeBackendFunction(...); } ✅ VERIFIED
kill %1

# 6. Confirm a `const g = globalThis; g.fetch(...)` alias is also rejected
npx vite --port 5196 --strictPort &
sleep 3
curl -s http://localhost:5196/src/aliasedFetch.backend.ts | grep -o 'Using "fetch" is not supported in backend function code'
# Expected: Using "fetch" is not supported in backend function code ✅ VERIFIED
kill %1
# Automated pass
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 28 passed, 28 total / Tests: 399 passed, 399 total ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, exit 0 ✅ VERIFIED

Blast Radius

  • Scoped to .backend.ts files and the local helper modules they import. No feature flag — production's real sandbox already blocks both restricted patterns, so these are new build-time errors for code that wasn't usable in production anyway; crypto/Intl only ever warn, never fail a build.
  • Best-effort, defense-in-depth, not exhaustive:
    • The import check catches static import specifiers and a dynamic import() with a string/template-literal specifier — not require() or a runtime-computed specifier.
    • The global-reference checks resolve a bare reference, a globalThis/global-qualified access (including a const alias chain), and destructuring — not a reference reached through a reassignable (let) binding or a fully dynamic computed key.
  • Risk: low. No behavior change for any file that doesn't import a Node built-in or reference one of the four restricted globals. Files referencing crypto/Intl get an additional warn-level log line only.
  • The checks run before the existing zero-exports check, intentionally: a no-export .backend.ts file that also imports a banned module now hard-fails instead of being silently warned-and-stripped — catching the banned pattern as soon as it's written.

Out of Scope / Follow-ups

Item Status Next step
Ship backend-function-globals.d.ts (ambient TypeScript type for $ that omits Deno/process/Node-builtin globals) Deferred Editor-only DX polish, not an enforced guarantee — this PR's checks already enforce the restriction regardless of what types an author's editor shows. Getting a hand-written .d.ts into the published dist/ tarball requires new build-tooling wiring in packages/tools/src/rollupConfig.mjs (shared by all 5 published bundler plugins), which is disproportionate scope for this PR. Revisit once a scaffold tool exists to actually wire the type into a consumer's tsconfig.json.
Revisit/remove both checks once backend-functions v2 ships Deferred v2's Terrapin-based sandbox will allow fetch; not blocking today's v1 rollout

Documentation

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from 84b9e52 to ec0f520 Compare August 7, 2026 20:35
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from ec0f520 to e17c9c8 Compare August 21, 2026 05:21
@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
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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 build-time restrictions for unsupported Node built-ins and network globals in backend functions.

Changes:

  • Adds AST validation for Node built-in imports and restricted globals.
  • Integrates validation into the Vite backend transform.
  • Adds unit and transform-level tests.

Reviewed changes

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

Show a summary per file
File Description
packages/plugins/apps/src/vite/index.ts Runs backend restrictions during transformation.
packages/plugins/apps/src/vite/index.test.ts Tests transform-level built-in rejection.
packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts Detects unresolved restricted globals.
packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts Tests global detection and shadowing.
packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts Detects Node built-in imports.
packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts Tests import restrictions and exceptions.
Suppressed comments (2)

packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts:40

  • The suggested remedy is inaccurate for non-privileged built-ins such as path, util, or events: an Action Platform action is not a replacement for those APIs. Mention standard JavaScript or a runtime-neutral package for portable functionality, reserving the Action Platform guidance for privileged operations, so the error remains actionable for every module this guard rejects.
                    `Backend functions run in a restricted environment and must use an Action ` +
                    `Platform action ($.Actions or an @datadog/action-catalog typed wrapper) instead: ${filePath}`,

packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts:84

  • Repository guidance disallows passing a function call directly into another call. Store the import declaration first so this test follows that rule.
        const ast = program([importDecl(source)]);

💡 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/backend/ast-parsing/reject-restricted-globals.ts Outdated
Comment thread packages/plugins/apps/src/vite/index.ts Outdated
Comment thread packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts Outdated
Comment thread packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts Outdated

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from f9ef9c7 to 5de2a92 Compare August 26, 2026 03:07
@tyffical
tyffical requested a balanced review from Copilot August 26, 2026 16:31
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 27, 2026
…nd files

Static AST checks run against every *.backend.ts file (and its nested
backend-module imports) at build/dev-server time: importing a Node
built-in, or referencing a network global (fetch, XMLHttpRequest, etc.)
directly or via globalThis, fails the build with a clear error instead
of surfacing as a runtime failure inside Datadog's execution sandbox.
Closes a series of edge cases found through iterative review of the
Node-builtin-import and restricted-globals checks: global-alias and
dynamic-import bypasses, template-literal and default-parameter
destructuring bypasses, a quoted-key bypass, and a globalThis.globalThis
self-reference bypass. Also adds warnAboutDivergentGlobals for
crypto/Intl, extracts a shared ambient-global-access traversal reused by
the reject/warn checks, wires a nested backend-static-checks-plugin that
re-runs all checks against the whole module graph (not just the entry
file), fixes a temp-dir cleanup failure masking a real build error, and
clarifies error wording for built-in-import rejections.
…s plugins

backend-static-checks-plugin.ts copied VIRTUAL_MODULE_ID_RE,
normalizeViteModuleId, and isViteVirtualModuleId verbatim from its
sibling backend-module-graph-collector.ts instead of importing them,
so a future change to virtual-id detection would need to be applied
in both places. Also computes scopeAnalysis once in the fallback
(no cached record) branch instead of leaving it uncomputed, so
rejectRestrictedGlobals and warnAboutDivergentGlobals stop each
independently re-walking the same AST.
rejectRestrictedGlobals and warnAboutDivergentGlobals each computed
their own eslint-scope analysis of the same AST on every backend-file
transform (including every dev-server HMR re-transform), despite both
already accepting a precomputed one to avoid exactly this.
…call argument

Replaces the eslint-scope-vs-estree as-cast in ambient-global-access.ts
with a genuine type guard (isVariableDeclaratorNode), and rewrites
type-guards.ts's own as-casts (isStringLiteral,
isNoSubstitutionTemplateLiteral) to narrow via the `in` operator
instead. Also un-inlines a parseAst(...) call passed directly as a
createParsedModuleRecord argument in backend-static-checks-plugin.test.ts.

These were previously left as-is after a bot flagged them, on the
mistaken belief that no repo convention banned the pattern; the
repo-root CLAUDE.md does.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from 90a0438 to 4480458 Compare August 27, 2026 05:33
@tyffical
tyffical requested a balanced review from Copilot August 27, 2026 05:45
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

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 19 out of 19 changed files in this pull request and generated 3 comments.

Suppressed comments (3)

packages/plugins/apps/src/vite/build-backend-functions.test.ts:52

  • This double assertion bypasses type checking for the mocked build function. Define it against typeof build directly so the test remains checked against Vite's build contract.
                failingViteBuild as unknown as typeof build,

packages/plugins/apps/src/vite/build-backend-functions.test.ts:75

  • This double assertion bypasses type checking for the mocked build function. Define it against typeof build directly so the test remains checked against Vite's build contract.
                failingViteBuild as unknown as typeof build,

packages/plugins/apps/src/vite/build-backend-functions.test.ts:99

  • This double assertion bypasses type checking for the mocked build function. Define it against typeof build directly so the test remains checked against Vite's build contract.
                    failingViteBuild as unknown as typeof build,

Comment thread packages/plugins/apps/src/vite/index.ts Outdated
handler(code, id) {
const ast = this.parse(code);
// Shared so rejectRestrictedGlobals/warnAboutDivergentGlobals don't each independently re-walk the same AST to build the same scope graph.
const scopeAnalysis = analyzeModuleScope(ensureProgram(ast, id));

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.

Fixed in bca0b05 — assigned ensureProgram(...) to a named local before passing it to analyzeModuleScope.

Comment thread packages/plugins/apps/src/vite/backend-static-checks-plugin.ts Outdated
Comment on lines +66 to +67
throw new Error(
`Importing Node built-in module "${value}" is not supported in backend function code. ` +

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.

Fixed the PR description — updated all 3 grep patterns/expected outputs to match the actual emitted message ("not supported in backend function code"), and refreshed the stale test-count claim (28 suites / 399 tests) while I was in there.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 4480458934

ℹ️ 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".

Two call sites passed ensureProgram(...) directly as analyzeModuleScope's
argument, violating the repo's no-inlined-function-call-arguments
convention.
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