Skip to content

[APPS-2792] Add: scope process.env to an allowlist during local execution (Secret Store parity) - #493

Draft
tyffical wants to merge 1 commit into
tiffany.trinh/apps-2792-runtime-network-guardfrom
tiffany.trinh/apps-2792-secret-store-parity
Draft

[APPS-2792] Add: scope process.env to an allowlist during local execution (Secret Store parity)#493
tyffical wants to merge 1 commit into
tiffany.trinh/apps-2792-runtime-network-guardfrom
tiffany.trinh/apps-2792-secret-store-parity

Conversation

@tyffical

@tyffical tyffical commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions. Milestone 8 in the Kickoff doc, "Secret Store parity for local execution." Stacked on the Sandboxing runtime guard ([APPS-2792] Add: runtime network/subprocess guard for local execution #484).
  • Backend functions now run in-process inside the Vite dev server instead of a fresh Deno subprocess. Without this change, a customer's function reading process.env directly would see the dev server's entire real environment — including DD_API_KEY/DD_APP_KEY (the dev server's own credentials) and the developer's own unrelated shell secrets (AWS keys, etc). Production has no equivalent gap: each execution gets a fresh Deno subprocess with --allow-env scoped to exactly the resolved credential names.

Architecture

runScriptLocally()
   │
   ├─ runWithScopedEnv(buildScopedEnv({}), () =>
   │     runBlocked(() => fn(...args))      ← network-guard, unchanged
   │  )                                      ← env-guard, new
   │
   │  buildScopedEnv({}) returns only { PATH, HOME, NODE_ENV, TMPDIR }
   │  (whichever are set in the real env) — never a copy of process.env.
   │
   ▼
customer function body executes with the scoped env only
   │
   ▼ (success, throw, or timeout)
real process.env restored — via runWithScopedEnv's own finally, or
forceResetEnv() in the timeout handler if fn never settles

env-guard.ts mirrors network-guard.ts's generation-counter design exactly: a monotonic currentGeneration, bumped by every runWithScopedEnv call and by forceResetEnv, so a late restore from an abandoned (timed-out) execution can't clobber a newer execution's still-active scoped window.

Changes

What changed File
New buildScopedEnv/runWithScopedEnv/forceResetEnv, scoping process.env to PATH/HOME/NODE_ENV/TMPDIR for the duration of a local execution. env-guard.ts
Full unit coverage: allowlist filtering, Custom Credentials merge point, restore-on-throw, and the abandoned/newer-execution race (mirrors network-guard.test.ts's own version of this test). env-guard.test.ts
Wired runWithScopedEnv/buildScopedEnv around the customer function call in runScriptLocally, nested with the existing runBlocked network guard. Added forceResetEnv() alongside the existing forceReset() in the timeout handler. local-execution.ts
Integration tests through executeScriptLocally: DD_API_KEY and an AWS-like credential are never visible to the customer function; PATH/HOME/NODE_ENV/TMPDIR remain visible; real process.env is restored after both success and throw. local-execution.test.ts

Two deliberate scope decisions, both documented in the RFC's Security section:

  1. The safe allowlist is intentionally narrow (PATH/HOME/NODE_ENV/TMPDIR only) — it does not include build-plugins' own OVERRIDE_VARIABLES (DD_API_KEY, DD_APP_KEY, DD_SITE, etc.). Those are the dev server's own credentials and must never reach customer code.
  2. Custom Credentials resolution (e.g. a declared STRIPE_API_KEY) remains an open, undecided question — buildScopedEnv is always called with {} for now, matching production's own "skip on unresolvable, never fail the task" behavior. The parameter is a forward-looking extension point only; no local-override mechanism exists yet.

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/env-guard.test.ts packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 2 passed / Tests: 45 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 26 passed / Tests: 357 passed ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint 'packages/plugins/apps/**/*.ts' --quiet
# Expected: no output, clean exit ✅ VERIFIED

Real manual QA against a scaffolded app, per the Testing and QA Guide:

# npm create @datadog/apps@latest -- qa-test-app --template vite-react -y, npm link @datadog/vite-plugin,
# add a backend function reading process.env.DD_API_KEY/AWS_SECRET_ACCESS_KEY/PATH, npm run dev
curl -X POST http://localhost:5180/__dd/executeAction -d '{"functionName":"...checkEnv","args":[]}'
# {"success":true,"result":{"data":{"apiKeyVisible":false,"awsKeyVisible":false,"path":"visible"}}} ✅ VERIFIED

Confirmed with the real dev server process holding DD_API_KEY/AWS_SECRET_ACCESS_KEY in its actual environment (via DD_API_KEY=... AWS_SECRET_ACCESS_KEY=... npm run dev) — neither leaked to the customer function, PATH stayed visible.

Blast Radius

  • No behavior change to npm run dev:verify's cloud path — this only affects the in-process local-execution branch.
  • Risk: low-medium. This is a real security boundary (it's the only thing standing between customer code and the dev server's own credentials), but it's purely additive, well-isolated to runScriptLocally, and follows an already-reviewed pattern (network-guard.ts).

Out of Scope / Follow-ups

Item Status Next step
Custom Credentials real resolution (e.g. proxying through $.Actions's own channel) Open, undecided Explicit design decision needed before treating Secret Store parity as fully closed — see RFC's "Open tension, not yet resolved" row
#security review of the scoped-process.env design Not yet requested Flag alongside the closure-scoping review this milestone already requires

Documentation

@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: 2311dbe9e4

ℹ️ 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 +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Scope the environment while evaluating customer modules

When a backend module reads an environment variable at module scope, loadModule(...) executes that customer code before runWithScopedEnv is entered, so code such as const key = process.env.DD_API_KEY can retain the dev server credential and return or use it later from the guarded function. The environment must be scoped during customer-module evaluation as well, even if Vite's own transform work needs a separate unscoped phase.

Useful? React with 👍 / 👎.

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.

Same underlying cause as the analogous, already-accepted network-guard gap in local-execution.ts (a customer module's own top-level code runs before any guard is entered, since loadModule needs real network/fs access for Vite's own transform pipeline — see that file's own doc comment). Confirmed real for env-guard too: a module-scope const key = process.env.DD_API_KEY would see the real value. Same fix shape as the network-guard case would need (scoping before loadModule, which previously broke the real dev-server integration test outright when tried for network-guard) — not attempting a different fix here without re-litigating that same tradeoff. Flagging alongside the post-timeout gap above for the #security review.

// would otherwise leave process.env scoped for the rest of this
// process.
forceReset();
forceResetEnv();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not expose the real environment after a timeout

When a customer function continues after exceeding timeoutMs (for example, it awaits a timer and then resumes), the timeout wins the race but does not cancel that function; this reset consequently exposes the real process.env to the still-running customer continuation. Such a function can simply wait past the timeout and then read DD_API_KEY, AWS credentials, or any other shell secret, defeating the new isolation guarantee.

Useful? React with 👍 / 👎.

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.

Confirmed real, and not something this PR can close on its own. It's the same root cause as the RFC's already-accepted 'abandoned, not cancelled' execution model (Decisions and Trade-Offs, and Milestone 7's own confirmed findings): a timed-out function isn't cancelled, just abandoned, so forceResetEnv() restoring the real env for the next execution necessarily also restores it underneath the still-running old one. Closing this fully needs either true per-execution process isolation or an AsyncLocalStorage-based proxy over process.env (replacing the whole-object-swap approach here with per-access-context resolution) — both bigger than this milestone's scope, and the latter has the same category of complexity the RFC already deferred for action-catalog/apps-backend's own registration scoping. Flagging this explicitly as a residual gap for the Secret Store parity #security review this milestone already requires, rather than leaving it implicit.

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 scopes environment variables during local backend-function execution to reduce secret exposure.

Changes:

  • Adds an environment allowlist guard and reset handling.
  • Integrates the guard with local execution and timeouts.
  • Adds unit and integration coverage.

Reviewed changes

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

File Description
env-guard.ts Implements environment scoping and restoration.
env-guard.test.ts Tests filtering, restoration, and generation handling.
local-execution.ts Applies and resets the environment guard.
local-execution.test.ts Tests local-execution environment behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment on lines +447 to +449
const result = await runWithScopedEnv(buildScopedEnv({}), () =>
runBlocked(() => fn(...args)),
);

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.

Same root question as the analogous finding on PR #484's local-execution.ts (network-guard side) — traced through carefully and it doesn't currently reproduce, for the same reason: the abandoned check runs synchronously with no await before either guard is entered. See my reply on #484 for the regression test that proves it end-to-end through both runBlocked and runWithScopedEnv together (they're nested in the same call).

Comment thread packages/plugins/apps/src/vite/env-guard.ts Outdated
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 2311dbe to ffeab70 Compare August 24, 2026 16:56
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch 2 times, most recently from 444e9a0 to 23788af Compare August 24, 2026 19:13
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 23788af to 2c0613b Compare August 24, 2026 19:28
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 170fb1e to 2565a46 Compare August 24, 2026 20:12
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 2565a46 to 9597d94 Compare August 25, 2026 00:29
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 9597d94 to 5769358 Compare August 25, 2026 01:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 5769358 to d0e7887 Compare August 25, 2026 15:29
@tyffical
tyffical requested a balanced review from Copilot August 25, 2026 15:46

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from d0e7887 to ac28c6b Compare August 25, 2026 18:44
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from 4be6a31 to 0662e77 Compare August 26, 2026 01:49
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Aug 26, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 2 Pipeline jobs failed

Continuous Integration | Unit tests — ❌ 21 tests failed · 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

21 tests failed due to missing module '@rollup/rollup-linux-x64-gnu'.

Showing tests most relevant to this failure.

❌ Bundling Bundler: esbuild Should add the correct files to @datadog/esbuild-plugin. from ../tools/src/rollupConfig.test.ts
Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try \`npm i\` again after removing both package-lock.json and node_modules directory.
❌ Bundling Bundler: esbuild Should expose loadable publish artifacts for @datadog/esbuild-plugin. from ../tools/src/rollupConfig.test.ts
Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try \`npm i\` again after removing both package-lock.json and node_modules directory.
❌ Bundling Bundler: esbuild Should not throw on easy project. from ../tools/src/rollupConfig.test.ts
Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try \`npm i\` again after removing both package-lock.json and node_modules directory.
↳ and 18 more — View all
Continuous Integration | End to End — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

16 failed tests due to timeout waiting for dynamic module '@rollup/rollup-linux-x64-gnu'. Check if dependencies are installed correctly and try removing package-lock.json and node_modules.

📋 Copy fix prompt
CI on my pull request is failing. Help me find and fix the root cause of each failing job below — they were flagged as caused by changes in this PR, so focus on the diff. For each job, explain the failure and propose a fix.

Before you start, set up the Datadog software-delivery tooling so you can
query the CI data yourself:

1. Check whether you already have the Datadog software-delivery MCP tools
   (e.g. a `search_datadog_ci_pipeline_events` tool) and the `unblock-pr` skill.
2. If either is missing, STOP and ask me for permission before installing
   anything. Do not install or run anything until I have said yes.
3. Only with my explicit approval, set up the Datadog software-delivery MCP
   server and skills by following:
     https://docs.datadoghq.com/getting_started/software_delivery_mcp_tools/
   then restart so the skill is picked up.
4. If I decline, skip all of the above and work from the context below alone.

Then run /unblock-pr — it will pull the CI data itself. The job context below is what we already know.

If /unblock-pr is not available — because I declined the setup above, or it did not install — work from the context below instead.

Datadog has already classified this failure as caused by changes in this PR.
Take that as given and work the fix:

1. Locate the change. Diff this branch against its base and find the change
   that produces this error. Explain the mechanism, don't just name a file:
     git fetch origin && git diff $(git merge-base origin/tiffany.trinh/apps-2792-runtime-network-guard HEAD)...HEAD
2. Reproduce it locally. Run the failing job's command or test before
   proposing anything.
3. Propose the smallest fix that addresses the root cause — not a workaround,
   not a broadened assertion, not a disabled or skipped test.
4. Re-run the same command to confirm, and say exactly what you ran.
5. If the failure turns out to be intermittent rather than deterministic, say
   so plainly instead of "fixing" it — that is a flaky test, and patching it
   hides the problem.

If the right move is to re-run the job rather than change code, use the job
link in the context below. For GitHub Actions: `gh run rerun <run-id> --failed`,
where the run ID is the number after `/runs/` in that URL (not the trailing
number, which is the job ID).

Branch: tiffany.trinh/apps-2792-secret-store-parity

Continuous Integration | Unit tests
Commit: 101e550c4b7cc64aabcf610b007429743b9a15ce
Error (code / test):
21 tests failed due to missing module '@rollup/rollup-linux-x64-gnu'.
CI job: https://github.com/DataDog/build-plugins/actions/runs/32930846889/job/98062649483

Continuous Integration | End to End
Commit: 101e550c4b7cc64aabcf610b007429743b9a15ce
Error (code / test):
16 failed tests due to timeout waiting for dynamic module '@rollup/rollup-linux-x64-gnu'. Check if dependencies are installed correctly and try removing package-lock.json and node_modules.
CI job: https://github.com/DataDog/build-plugins/actions/runs/32930846889/job/98062649678

ℹ️ Info

No other issues found (see more)

❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 75fdc4b | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch 2 times, most recently from fb70b99 to e7223f7 Compare August 26, 2026 02:41
…l execution

Milestone 8 (Secret Store parity). Without this, a customer's backend
function reads the dev server's own real process.env with no
restriction at all — leaking the developer's entire shell (AWS
credentials, other API keys, the dev server's own DD_API_KEY/DD_APP_KEY)
into arbitrary, possibly third-party code. Production has no equivalent
gap: each execution gets a fresh Deno subprocess with --allow-env scoped
to exactly the resolved credential names; in-process local execution has
no process boundary to rely on, so this closes the gap at the module
level instead, mirroring network-guard.ts's monkey-patch approach and
reusing the same shared generation-counter protection against the same
abandoned-execution race class.

Also blocks reads of /proc/self/environ (and /proc/<pid>/environ) while
a scoped-env window is active: swapping the JS-level process.env object
alone isn't a real boundary on Linux, since that kernel-backed file
exposes the process's real startup environment unaffected by
reassigning process.env.

Custom Credentials resolution (e.g. STRIPE_API_KEY) remains an open,
undecided question — buildScopedEnv's customCredentials parameter is a
forward-looking extension point, always {} for now.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-secret-store-parity branch from e7223f7 to 75fdc4b Compare August 26, 2026 04:36
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