Skip to content

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

Draft
tyffical wants to merge 4 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 4 commits into
masterfrom
tiffany.trinh/apps-2792-sandboxing-import-restriction

Conversation

@tyffical

@tyfficaltyffical 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.
  • 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 changedFile
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

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 branchcd~/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
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 ✅ VERIFIEDkill %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 ✅ VERIFIEDkill %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(...); } ✅ VERIFIEDkill %1
# Automated pass
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 24 passed, 24 total / Tests: 306 passed, 306 total ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, exit 0 ✅ VERIFIED

Blast Radius

  • Scoped to .backend.ts files' static imports and top-level global references only. No feature flag — this is a build-time compile error for a pattern (Node built-ins, or raw network globals) that wasn't previously usable in production anyway, since production's real sandbox already blocks both.
  • Best-effort, defense-in-depth: the import check only catches static import specifiers (not require() or a dynamically computed import()); the global-reference check only catches references eslint-scope can't resolve to a local declaration/import of the same name.
  • Risk: low. No behavioral change for any existing .backend.ts file that doesn't import a Node built-in or reference one of the four restricted globals directly (306/306 existing apps-plugin tests pass unchanged).

Out of Scope / Follow-ups

ItemStatusNext step
Ship backend-function-globals.d.ts (ambient TypeScript type for $ that omits Deno/process/Node-builtin globals)DeferredEditor-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 shipsDeferredv2's Terrapin-based sandbox will allow fetch; not blocking today's v1 rollout

Documentation

Backend functions run in a restricted environment (isomorphic/fetch-based
APIs only), so direct static imports of Node built-in modules (fs,
child_process, net, etc.) in .backend.ts files are now rejected at build
time in the Vite transform hook, right after AST parsing.
This is a best-effort, defense-in-depth check on static import specifiers
only — it does not catch require() or dynamic import() of a computed
specifier.
Backend functions have no raw network access in every real production
runtime -- Deno's --allow-net is off today, and the planned
Terrapin-based v2 sandbox restricts it the same way -- so any outbound
call must go through an Action Platform action ($.Actions or an
@datadog/action-catalog typed wrapper), never a direct HTTP client.
rejectNodeBuiltinImports only catches import specifiers; fetch and
friends need no import at all, so this adds a separate,
eslint-scope-based check for unshadowed references to fetch,
XMLHttpRequest, WebSocket, and EventSource.
Also corrects 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.
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-sandboxing-import-restriction branch from ec0f520 to e17c9c8CompareAugust 21, 2026 05:21
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 16:24
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@cursor review
@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:e17c9c8754

ℹ️ 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 threadpackages/plugins/apps/src/vite/index.ts

CopilotAI 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
FileDescription
packages/plugins/apps/src/vite/index.tsRuns backend restrictions during transformation.
packages/plugins/apps/src/vite/index.test.tsTests transform-level built-in rejection.
packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.tsDetects unresolved restricted globals.
packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.tsTests global detection and shadowing.
packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.tsDetects Node built-in imports.
packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.tsTests 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 threadpackages/plugins/apps/src/vite/index.ts
…red global bypasses
reject-node-builtin-imports only walked ImportDeclarations, so a named
re-export like `export { readFile as handler } from 'node:fs'` loaded the
same builtin without ever binding a local identifier, sailing through
undetected. Now also walks ExportNamedDeclaration nodes with a source,
reusing the same restricted-source check.
reject-restricted-globals only caught bare identifier references resolved
via eslint-scope, missing globalThis.fetch(...)/globalThis['fetch'] (ESTree
represents the property as a member access, not a reference eslint-scope
tracks) and const { fetch } = globalThis (binds a local that then shadows
the scope-based check entirely). Added a dedicated AST walk for both forms.
Also replaced an `as` cast in a test with the codebase's existing
intersection-type pattern for ESTree's incomplete parser-metadata types, and
named two inlined function-call results before passing them to another call,
per this repo's no-inlined-call-arguments convention.
…ule imports
reject-node-builtin-imports and reject-restricted-globals only ran against
the entry .backend.ts file matched by the outer Vite transform filter. A
backend function importing a local app helper module is a supported flow,
but the helper's own source was never scanned by either check — only the
nested backend build (which bundles a single function's own module graph)
actually walks into it, so a helper doing `import fs from 'fs'` or calling a
bare `fetch()` shipped undetected even though the entry file itself was
clean.
Adds a Vite plugin that re-runs both checks against every app-local module
the nested backend build resolves, via the same moduleParsed hook and
module-id normalization/exclusion the connection-ID collector already uses
for the same module set. Wired into both the production build-backend-functions
path and the dev bundleBackendFunction path, matching how the connection-ID
collector itself is wired into both.
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 20:01
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@codex review

CopilotAI 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 12 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

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

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

  • A literal dynamic import still bypasses this check: await import('node:fs') is represented as an ImportExpression (or Rollup's import CallExpression), while this loop only visits top-level declarations. Because the specifier is static, this can be rejected at build time just like an ImportDeclaration; otherwise the built-in reaches the backend bundle and fails only at runtime. Walk both dynamic-import AST representations and reject literal restricted sources, while retaining the documented exemption for computed specifiers.
 for (const node of program.body) {
if (node.type === 'ImportDeclaration' && !isTypeOnly(node)) {
rejectIfRestrictedSource(node.source, filePath);
continue;

Comment on lines +76 to +78
function restrictedGlobalThisMemberName(node: MemberExpression): string | undefined {
if (node.object.type !== 'Identifier' || node.object.name !== GLOBAL_THIS_NAME) {
return undefined;

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:b32490a571

ℹ️ 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 on lines +39 to +40
if (node.type === 'ExportNamedDeclaration' && node.source && !isTypeOnly(node)) {
rejectIfRestrictedSource(node.source, filePath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject built-ins re-exported with export star

When a reachable helper contains export * from 'node:fs' or export * as fs from 'node:fs', Rollup represents it as an ExportAllDeclaration, so this named-export-only branch never validates the source and the restricted built-in can still enter the backend bundle. Handle non-type ExportAllDeclaration sources alongside named re-exports.

Useful? React with 👍 / 👎.

Comment on lines +77 to +78
if (node.object.type !== 'Identifier' || node.object.name !== GLOBAL_THIS_NAME) {
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect locally shadowed globalThis bindings

When backend code shadows globalThis, such as function run(globalThis) { return globalThis.fetch(); }, this name-only test rejects the local object's method even though no ambient network global is accessed. Resolve the object identifier through the existing scope analysis and reject it only when it is the unresolved ambient globalThis; the destructuring check needs the same distinction.

Useful? React with 👍 / 👎.

Comment on lines +110 to +114
if (
property.type === 'RestElement' ||
property.computed ||
property.key.type !== 'Identifier'
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Inspect computed globalThis destructuring keys

When code uses const { ['fetch']: request } = globalThis, this condition skips the computed property and the later request reference resolves locally, so neither validation rejects the raw network access. The fresh evidence after the prior fix is the newly added property.computed skip here; inspect computed string-literal keys just as the member-expression path does.

Useful? React with 👍 / 👎.

Sign up for freeto 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

@tyffical