Skip to content

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

Draft
tyffical wants to merge 5 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 5 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. Backend functions run in a restricted environment with no unrestricted filesystem/process/network access — under today's v1 runtime, that includes 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-in modules (fs, child_process, net, etc.) in .backend.ts files are rejected at build time, so an author gets immediate, actionable feedback instead of code that silently behaves differently (or breaks) once local Node execution lands.
  • Network-capable globals (fetch, XMLHttpRequest, WebSocket, EventSource) need a separate check: they're bare globals, not imports, so import-specifier restriction can't catch them. This closes a real trap: fetch works fine during local dev (nothing stopped it before this check existed) but fails once the app is published, since production's sandbox blocks it. Node's own global alias for the ambient global object reaches the same restricted globals and is treated identically.
  • crypto and Intl are legitimate, working APIs in both runtimes, but their concrete behavior (RNG implementation, bundled ICU data) isn't guaranteed identical between local execution (Node) and production (Deno) — part of the RFC's prod-parity divergence list. These get a warning, never a rejection: editor/console-time guidance pointing authors at npm run dev:verify's real cloud round trip as the actual parity gate.
  • A separate, complementary effort (web-ui#340206) adds AI-authoring guidance steering generated code away from fetch in the first place. That reduces how often this gets written at all, but only this build-time check guarantees it never ships, regardless of whether the code came from an AI, a human, or a copy-pasted snippet. Both layers exist for a reason — this PR isn't superseded by that guidance work.
  • This restriction (and the AI-authoring guidance) is v1-specific: backend functions' planned v2 (Terrapin-based) sandbox will lift it. Legacy (pre-v2) apps are the ones that need it.
  • These are the two "Layer 2" static defenses proposed in the design doc's Sandboxing section; the companion item (ambient TypeScript globals for $ that omit Node-specific types) is deferred — see Out of Scope below.

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
Added rejectRestrictedGlobals, an eslint-scope-based check that throws on any unshadowed reference to fetch/XMLHttpRequest/WebSocket/EventSource — i.e. any reference that doesn't resolve to a local declaration or import sharing the same name, meaning it falls through to the real ambient global. reject-restricted-globals.ts
Corrected rejectNodeBuiltinImports' 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
Wired both checks into the Vite transform hook, right after this.parse(code) and before export extraction. vite/index.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 or parameter that happens to be 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
Added warnAboutDivergentGlobals, which warns (once per distinct global per file, never rejects) on crypto/Intl references — bare, globalThis-qualified, or destructured, including multi-property and computed-key destructures, with the same eslint-scope shadowing check as the two reject-style helpers. warn-divergent-globals.ts
rejectRestrictedGlobals now treats Node's global alias the same as globalThis in both qualified-access forms (global.fetch, destructuring off global). reject-restricted-globals.ts
rejectNodeBuiltinImports now 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
Added createBackendStaticChecksPlugin, a nested Vite plugin that re-runs all three static checks (including the new divergent-globals warning) against every app-local module the backend build resolves — not just the .backend.ts entry the outer transform hook sees — so an imported helper can't ship an undetected Node built-in, restricted global, or divergence warning. Wired into both the production build and the dev server's own bundling path. backend-static-checks-plugin.ts
Extracted forEachAmbientGlobalAccess, a shared traversal for every syntactic form that reaches globalThis/global (bare reference, qualified member access, destructuring), used by both rejectRestrictedGlobals and warnAboutDivergentGlobals so a bypass fix lands once instead of drifting between two hand-mirrored copies. ambient-global-access.ts
The shared traversal now also resolves a computed member/destructure key written as a no-substitution template literal (globalThis[\`fetch\`]), and a const alias of globalThis/global — including a chain of aliases — closing two bypasses a code-review pass found in the string-literal-only, no-alias-tracking version of these checks. ambient-global-access.ts
warnAboutDivergentGlobals now also fires on a destructuring assignment (not just a declaration) off globalThis, and warns — rather than silently skipping — a rest-destructure, matching coverage rejectRestrictedGlobals already had for both shapes. Its per-file dedup cache is now bounded so a long dev-server session can't grow it forever. warn-divergent-globals.ts
createBackendStaticChecksPlugin now reuses the connection-ID collector's already-parsed AST and scope analysis for a module instead of parsing and scope-analyzing it a second time. backend-static-checks-plugin.ts, backend-connection-id-collector.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.ts files'
# Expected: Importing Node built-in module "node:fs" is not supported in .backend.ts files ✅ 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.ts files'
# Expected: Using "fetch" is not supported in .backend.ts files ✅ 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.ts files'
# Expected: Using "fetch" is not supported in .backend.ts files ✅ VERIFIED
kill %1
# Automated pass
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 27 passed, 27 total / Tests: 387 passed, 387 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 — the Node-builtin and restricted-globals checks are build-time compile errors for patterns that wasn't previously usable in production anyway, since production's real sandbox already blocks both; the crypto/Intl check only ever logs a warning, never fails a build.
  • Best-effort, defense-in-depth: the import check catches static import specifiers and a dynamic import() whose specifier is a string or no-substitution template literal, but not require() or a specifier computed at runtime; the global-reference checks resolve a bare reference, a globalThis/global-qualified access (including a const alias chain), and destructuring, but not a reference reached through a let/reassignable binding or a fully dynamic (non-literal) computed key.
  • Risk: low. No build-failure behavioral change for any existing file that doesn't import a Node built-in or reference one of the four restricted globals (directly, via global, or via a literal dynamic import). Files that reference crypto/Intl now get an additional warn-level log line — no build failure, no output change.
  • The checks run before the existing zero-exports check, intentionally: a .backend.ts file with no exports that also imports a banned module now hard-fails the build instead of just warning-and-stripping — catching the banned pattern as soon as it's written rather than waiting for the file to also gain an export.

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
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.

@tyffical
tyffical requested a balanced review from Copilot August 26, 2026 02:55
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@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: f9ef9c758b

ℹ️ 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/build-backend-functions.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 15 out of 15 changed files in this pull request and generated 4 comments.

Suppressed comments (11)

Previously missed (3) — in code that hasn't changed since the last review.

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

  • This type assertion remains contrary to the repository's no-as-casts rule and to this test helper's comment that metadata can be built without a cast. Declare the specifier with the intersection type before passing it into importDecl.

This issue also appears in the following locations of the same file:

  • line 159
  • line 225
                } as ImportDeclaration['specifiers'][number] & TypeScriptImportExportMetadata,

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

  • This error is also raised for .backend.js/.backend.jsx/.backend.tsx entries and ordinary helper modules, so saying the current file is a .backend.ts file is inaccurate. Refer to “backend function code” (or the actual path) so the diagnostic matches every checked module.
    throw new Error(
        `Importing Node built-in module "${value}" is not supported in .backend.ts files. ` +
            `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/vite/backend-static-checks-plugin.test.ts:35

  • Repository guidance disallows passing a function call directly as another call's argument. Store the mock logger in a named local before creating the plugin.

This issue also appears in the following locations of the same file:

  • line 46
  • line 57
  • line 68
  • line 79
  • line 90
  • ...and 1 more
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

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

  • This is another as assertion used to attach TypeScript parser metadata, despite the repository rule prohibiting assertion escape hatches. Give the type-only specifier an explicit intersection-typed local instead.
                } as ImportDeclaration['specifiers'][number] & TypeScriptImportExportMetadata,

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

  • This re-export fixture still uses an as assertion, which violates the repository's no-assertion rule. Define an explicitly intersection-typed specifier local and pass that to the helper.
                } as ExportNamedDeclaration['specifiers'][number] & TypeScriptImportExportMetadata,

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:46

  • Store getMockLogger() in a local before passing it to createBackendStaticChecksPlugin, per the repository's no-inlined-function-call-arguments rule.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:57

  • Avoid constructing the logger directly inside the plugin factory arguments; name the mock logger first to follow the repository rule.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:68

  • This nested getMockLogger() call violates the repository's no-inlined-function-call-arguments rule. Extract it to a named local.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:107

  • Store the mock logger before calling createBackendStaticChecksPlugin; directly nesting getMockLogger() here violates the repository convention.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:79

  • Create the mock logger in a named local before passing it to the plugin factory, as required by the repository guidance.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts:90

  • The logger factory call should not be nested in another function's arguments. Extract it to a named local first.
        const plugin = createBackendStaticChecksPlugin('/project', getMockLogger());

Comment thread packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts Outdated
Comment thread packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.ts Outdated
…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 several ways the restricted-globals/import checks could be
bypassed (named re-export, globalThis-qualified access, destructuring,
computed-property access, shadowed globalThis, inline type-only
specifiers, export-star re-exports) and extends coverage to nested
backend-module imports reached from a *.backend.ts file. Also adds a
non-fatal warning (rather than a hard reject) for crypto/Intl divergence
between local dev and the production sandbox, since those globals behave
close enough for most cases to not warrant blocking the build.
…coverage

Treats Node's global alias the same as globalThis, rejects a literal
dynamic import() of a Node built-in, extends the divergent-globals
warning to helper modules the nested backend build resolves, and closes
a destructuring-assignment/rest-destructure bypass of the globalThis
checks. Adds a dev-server bundle-path regression test and tightens
several test/production comments for clarity.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from f9ef9c7 to 5de2a92 Compare August 26, 2026 03:07
…use parse

- Extract forEachAmbientGlobalAccess, a shared traversal for every syntactic
  form that reaches globalThis/global, used by both rejectRestrictedGlobals
  and warnAboutDivergentGlobals instead of two hand-mirrored copies
- Recognize a no-substitution template-literal computed key (globalThis[`fetch`])
  the same as a string literal, in the restricted/divergent-globals checks and
  the Node-builtin dynamic-import check
- Resolve a const alias chain of globalThis/global (const g = globalThis) so
  it's no longer a silent bypass of the restricted-globals check
- warnAboutDivergentGlobals now fires on a destructuring assignment (not just
  a declaration) off globalThis, and warns instead of silently skipping a
  rest-destructure, matching rejectRestrictedGlobals's existing coverage
- Bound warnAboutDivergentGlobals's per-file dedup cache so a long dev-server
  session can't grow it unbounded
- createBackendStaticChecksPlugin reuses the connection-ID collector's
  already-parsed AST and scope analysis instead of re-deriving both a second
  time for every module, per build
- Replace an `as` escape-hatch cast in reject-node-builtin-imports.test.ts
  with typed specifier-builder helpers
…dirs on abort, and clarify error wording

`function run({ fetch } = globalThis)` reaches globalThis/global through
an AssignmentPattern, a node shape the destructure walker didn't visit —
closes it in the shared ambient-global-access helper so both the reject
and warn checks pick it up together.

buildBackendFunctions leaked its mkdtemp-created temp directory whenever
a per-function vite.build() call threw (e.g. a static check rejecting a
reachable helper module) — the temp dir was only cleaned up by the
caller on success. Cleans it up on the abort path too.

Also corrects wording in reject-restricted-globals and
reject-node-builtin-imports: both checks also run against non-.backend.ts
helper modules reachable from a backend function (via the nested
static-checks plugin), so "not supported in backend function code" is
accurate where "not supported in .backend.ts files" wasn't.
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