Skip to content

[APPS-2961] Add: limit local call processes with Node's permission model - #617

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2961-child-permission-model
Oct 28, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2961-child-permission-model

Conversation

@tyffical

@tyffical tyffical commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Architecture

flowchart LR
    D["Vite dev server"] -->|"fork with --permission, --allow-fs-read, --allow-fs-write"| C["Call process"]
    C -->|"fetchModule over IPC"| G["Module-load guard"]
    G -->|"readable and not denied"| V["Vite SSR environment"]
    G -->|"outside the readable folders or in server.fs.deny"| R["Refusal"]
    V -->|"module code, or an error naming a readable file"| C
Loading

Changes

6 changes across local-execution-sandbox.ts, local-execution-process.ts, local-execution-child.ts, index.ts, the README and tests
What changed File
Starts each call under the permission model, from the real paths of the child entry and module runner: reads the project, its packages, Vite and server.fs.allow; writes only its temp dirs. local-execution-sandbox.ts, local-execution-process.ts
Refuses module loads outside those folders or in server.fs.deny, saying which, and strips Vite's load errors about unreadable files. local-execution-sandbox.ts
Keeps the in-process guards in the child without the permission model, or without --allow-inspector while debugging. local-execution-child.ts, local-execution-protocol.ts
Gives modules no import.meta.resolve under the permission model on a Node without module.registerHooks (added in 22.15), where Vite resolves it on a worker thread, and warns once per dev server. local-execution-child.ts, local-execution-process.ts
Passes Vite's server.fs settings and SSR resolver to the executor, and adds picomatch. index.ts, package.json
Documents the sandbox, with tests. README.md, local-execution-sandbox.test.ts, local-execution-process.integration.test.ts

QA Instructions

Check Result Evidence
Write outside the temp dirs Refused (ERR_ACCESS_DENIED); no file created "projectWrite":"refused: ERR_ACCESS_DENIED", project file exists after calls: false
Temp-dir and /tmp writes, fetch Allowed "tempWrite":"ok: x","sharedTmpWrite":"ok: written","fetch":"ok: 200"
Subprocess and worker Refused (ERR_ACCESS_DENIED) "spawn":"refused: ERR_ACCESS_DENIED","worker":"refused: ERR_ACCESS_DENIED"
.env and /etc/hosts module loads Refused, naming the reason; a project module loads can't load "/.env": it matches Vite's `server.fs.deny` , can't load "/private/etc/hosts": it's outside the folders a backend function can read
import.meta.resolve warning Once across 3 calls on Node 20.19.4; absent on Node 24.14.1 [warn|vite|apps] Local execution on Node.js 20.19.4 can't provide `import.meta.resolve` inside the sandbox; …
Dev server under --inspect Node 24.14.1 keeps the sandbox; Node 20.19.4 uses the in-process guards (EROFS) Debugger listening on ws://127.0.0.1:… per call
yarn typecheck:all  # Expected: exit 0
yarn lint           # Expected: exit 0
yarn cli integrity  # Expected: exit 0, no changes
TMPDIR=/private/tmp yarn test:unit packages/plugins/apps  # Expected: exit 0
yarn workspace @datadog/vite-plugin buildCmd  # Expected: exit 0
# The QA app below, with its project file outside /tmp, since /tmp is writable:
DD_API_KEY=qa-fake-api-key DD_APP_KEY=qa-fake-app-key <node 20.19.4|24> dev-qa.mjs ./app <build-plugins checkout> <checkout>/qa-sandbox-should-not-exist.txt  # Expected: the table above
<node 24> --inspect=127.0.0.1:0 dev-qa.mjs ./app <build-plugins checkout> <checkout>/qa-sandbox-should-not-exist.txt  # Expected: a debugger URL per call, same refusals
# On the Vite matrix PR's branch, with this branch's plugin packed (npm pack in packages/published/vite-plugin):
DD_APPS_TEST_VITE_VERSION=8.3.3 DD_APPS_TEST_PLUGIN_TGZ=<tgz> <node 20.19.4> --test packages/tests/src/apps-vite-versions/local-execution.mjs  # Expected: one import.meta.resolve warning
DD_APPS_TEST_VITE_VERSION=8.3.3 DD_APPS_TEST_PLUGIN_TGZ=<tgz> <node 24> --test packages/tests/src/apps-vite-versions/local-execution.mjs  # Expected: no warning
QA app and harness: dev-qa.mjs, app/
  • app/ also holds package.json ({ "name": "qa-app", "private": true, "type": "module" }), a .env (DD_API_KEY=from-dotenv), src/main.js importing greet, and node_modules/ linking vite and @datadog/vite-plugin to this checkout.
  • The process probes live in a dependency, node_modules/qa-probes ({ "name": "qa-probes", "type": "module", "main": "index.js" }), since backend function code can't import Node built-ins.
// dev-qa.mjs
// Starts the QA app's real Vite dev server with only the Datadog API mocked, then calls its
// backend functions through POST /__dd/executeAction.
// Usage: node dev-qa.mjs <appDir> <buildPluginsRepo> <fileOutsideTmp>
// Calls a backend function that tries what the sandbox refuses and allows.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import { createRequire } from 'node:module';
import path from 'node:path';

const [appDir, repoDir, projectFile] = process.argv.slice(2);
const appRequire = createRequire(path.join(appDir, 'package.json'));
const repoRequire = createRequire(path.join(repoDir, 'package.json'));
const { createServer } = await import(appRequire.resolve('vite'));
const nock = repoRequire('nock');

const api = nock('https://api.datadoghq.com').persist();
api.post('/api/v2/app-builder/queries/preview-async').reply((_uri, body) => {
    const fqn = body?.data?.attributes?.query?.properties?.spec?.fqn;
    const id = fqn === 'com.datadoghq.datatransformation.jsFunctionWithActions' ? 'rc' : 'act';
    return [200, { data: { id } }];
});
api.get('/api/v2/app-builder/queries/execution-long-polling/rc').reply(200, {
    data: {
        attributes: {
            done: true,
            outputs: {
                data: {
                    Source: {
                        initiator: { id: 'qa-user', orgId: 'qa-org' },
                        runAsUser: { id: 'qa-user', orgId: 'qa-org' },
                    },
                },
            },
        },
    },
});
api.get('/api/v2/app-builder/queries/execution-long-polling/act')
    .delay(300)
    .reply(200, { data: { attributes: { done: true, outputs: { status: 200 } } } });
nock.enableNetConnect('127.0.0.1');

const server = await createServer({
    root: appDir,
    configFile: path.join(appDir, 'vite.config.js'),
    logLevel: 'warn',
    server: { host: '127.0.0.1', port: 5617, strictPort: true },
});
await server.listen();
const base = server.resolvedUrls.local[0].replace(/\/$/, '');
console.log(`vite dev server on ${base} (vite ${appRequire('vite/package.json').version}, node ${process.version})`);

const stub = await fetch(`${base}/src/qa.backend.js`);
console.log(`GET /src/qa.backend.js -> ${stub.status}`);

const call = async (name, args = []) => {
    const pathHash = createHash('sha256').update('src/qa').digest('hex');
    const started = Date.now();
    const response = await fetch(`${base}/__dd/executeAction`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', origin: base },
        body: JSON.stringify({ functionName: `${pathHash}.${name}`, args }),
    });
    const text = await response.text();
    console.log(`QA ${name} -> ${response.status} in ${Date.now() - started}ms ${text}`);
};

const pong = http.createServer((_req, res) => res.end('pong'));
await new Promise((resolve) => pong.listen(0, '127.0.0.1', resolve));
const fetchUrl = `http://127.0.0.1:${pong.address().port}/`;

await call('greet', ['qa']);
await call('runSandboxProbes', [projectFile, fetchUrl]);
await call('runSandboxProbes', [projectFile, fetchUrl]);
console.log(`project file exists after calls: ${fs.existsSync(projectFile)}`);
pong.close();
console.log(`dev server pid ${process.pid}`);
await server.close();
console.log('server closed');
// app/vite.config.js
import { datadogVitePlugin } from '@datadog/vite-plugin/dist/src/index.mjs';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            logLevel: 'debug',
            auth: { site: 'datadoghq.com', apiKey: 'qa-fake-api-key', appKey: 'qa-fake-app-key' },
            apps: { enable: true, identifier: 'qa-app', name: 'qa-app' },
        }),
    ],
});
// app/src/qa.backend.js
import { probeSandbox } from './probes.js';

export async function greet(name) {
    return { hello: name };
}

export async function runSandboxProbes(projectFile, fetchUrl) {
    return probeSandbox(projectFile, fetchUrl);
}
// app/src/probes.js
import { forgeInvoke, probeProcess } from 'qa-probes';

export async function probeSandbox(projectFile, fetchUrl) {
    return {
        ...(await probeProcess(projectFile, fetchUrl)),
        dotenvLoad: await forgeInvoke('/.env?raw', 999_001),
        outsideLoad: await forgeInvoke('/etc/hosts?raw', 999_002),
        projectLoad: await forgeInvoke('/src/main.js?raw', 999_003),
    };
}
// app/node_modules/qa-probes/index.js
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Worker } from 'node:worker_threads';

const attempt = async (work) => {
    try {
        return `ok: ${await work()}`;
    } catch (error) {
        return `refused: ${error.code ?? error.message}`;
    }
};

export async function probeProcess(projectFile, fetchUrl) {
    return {
        projectWrite: await attempt(() => fs.writeFileSync(projectFile, 'x')),
        tempWrite: await attempt(() => {
            const file = path.join(os.tmpdir(), 'probe.txt');
            fs.writeFileSync(file, 'x');
            return fs.readFileSync(file, 'utf8');
        }),
        sharedTmpWrite: await attempt(() => {
            const file = `/tmp/dd-apps-qa-${process.pid}.txt`;
            fs.writeFileSync(file, 'x');
            fs.unlinkSync(file);
            return 'written';
        }),
        spawn: await attempt(() => {
            const result = spawnSync('echo', ['hi']);
            if (result.error) {
                throw result.error;
            }
            return String(result.stdout).trim();
        }),
        worker: await attempt(() => {
            const worker = new Worker('1', { eval: true });
            return worker.terminate().then(() => 'started');
        }),
        fetch: await attempt(async () => (await fetch(fetchUrl)).status),
        permissionModel: typeof process.permission,
        envKeys: Object.keys(process.env).sort().join(',') || 'none',
    };
}

// Sends the dev server the fetchModule request Vite's module runner would, for any URL.
export function forgeInvoke(url, id) {
    return new Promise((resolve) => {
        process.on('message', (message) => {
            if (message?.type === 'invoke-result' && message.id === id) {
                const { error, result } = message.response ?? {};
                resolve(error ? `refused: ${error.message}` : `ok: loaded ${result?.file ?? result?.id}`);
            }
        });
        process.send({
            type: 'invoke',
            id,
            payload: { type: 'custom', event: 'vite:invoke', data: { id: 'send', name: 'fetchModule', data: [url, undefined, {}] } },
        });
    });
}
  • Output on Node 24.14.1; Node 20.19.4 matches, plus the one warning:
vite dev server on http://127.0.0.1:5617 (vite 6.4.3, node v24.14.1)
QA greet -> 200 in 524ms {"success":true,"result":{"data":{"hello":"qa"}}}
QA runSandboxProbes -> 200 in 483ms {"success":true,"result":{"data":{"projectWrite":"refused: ERR_ACCESS_DENIED","tempWrite":"ok: x","sharedTmpWrite":"ok: written","spawn":"refused: ERR_ACCESS_DENIED","worker":"refused: ERR_ACCESS_DENIED","fetch":"ok: 200","permissionModel":"object","envKeys":"TMPDIR,__CF_USER_TEXT_ENCODING","dotenvLoad":"refused: Local execution can't load \"/.env\": it matches Vite's `server.fs.deny`, which a backend function can't load.","outsideLoad":"refused: Local execution can't load \"/private/etc/hosts\": it's outside the folders a backend function can read.","projectLoad":"ok: loaded <app>/src/main.js"}}}
project file exists after calls: false
Manual QA in a Vite 8 app against a Datadog staging org, at 15442202
DD_SITE=<staging site> node qa-run.mjs ./qa-app 5961 after > after.json
{
 "freshState": [
  1,
  1,
  1
 ],
 "sandbox": {
  "success": true,
  "result": {
   "data": {
    "projectWrite": "refused: ERR_ACCESS_DENIED",
    "tempWrite": "ok: x",
    "spawn": "refused: ERR_ACCESS_DENIED",
    "envKeys": "none"
   }
  }
 },
 "projectFileExists": false
}

Blast Radius

  • Local development only: /__dd/executeAction.
  • No feature flag. Medium risk: loads outside the readable folders fail unless server.fs.strict is false.

Out of Scope / Follow-ups

7 items deferred
Item Status Next step
A call can still read project files such as .env with fs. Accepted The permission model has no deny list; the checks target accidents.
Module-load checks see only the requested file, so a stylesheet's @import or a hand-made IPC request gets past them. Accepted Check the file Vite reads in a load hook.
A call can't import over IPC a module it wrote to its temp dir. Accepted Add the writable dirs if needed.
Node 20.0 to 20.6 keep only the last --allow-fs-read, and a comma in a readable path splits it. Unverified Use the guards if this shows up.
server.fs.deny also refuses SSR imports such as a .pem read with ?raw. Accepted Revisit if a backend function needs one.
A symlink in a writable folder reaches outside the grant (a Node limitation). Accepted None
Smaller module-load check limits:
  • a cached-realpath race
  • only the first candidate file checked on a load error
  • relative URLs skip the name-based deny check
  • picomatch vs Vite's deny glob
  • nested node_modules symlinks
  • per-call path collection cost
Accepted None: a parity aid, not a security boundary.

Documentation

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Live Debugger Runtime Benchmark

SDK-loaded dormant-probe runtime overhead, measured against an uninstrumented bundle in the same browser session.

Browser Workload Quality Per-call overhead upper
chrome Hot clean <= 4.75 ns
chrome Tiny clean <= 0.03 ns
firefox Hot clean <= 9.95 ns
firefox Tiny clean <= 0.19 ns
safari Hot clean <= 5.32 ns
safari Tiny clean <= 0.22 ns

What do the Tiny and Hot workloads represent?

Browser Debugger SDK: 7.16.2 · built 2026-10-27 · S3 ETag 872de873

Full diagnostics
browser  workload  quality  per-call overhead upper  overhead upper          95% CI        A/A diag        block CI  acf(1)   baseline  instrumented                         samples
-------  --------  -------  -----------------------  --------------  --------------  --------------  --------------  ------  ---------  ------------  ------------------------------
chrome   Hot       clean                 <= 4.75 ns        <= 8.55%   4.73..4.75 ns  -0.01..0.01 ns   4.72..4.75 ns    0.16  50.892 ms     55.230 ms   102 (trim 20%, outliers 5.9%)
chrome   Tiny      clean                 <= 0.03 ns        <= 0.31%  -0.02..0.03 ns  -0.01..0.03 ns  -0.02..0.02 ns   -0.05  55.015 ms     55.010 ms  102 (trim 20%, outliers 15.7%)
firefox  Hot       clean                 <= 9.95 ns       <= 36.25%   9.93..9.94 ns  -0.00..0.01 ns   9.92..9.95 ns   -0.01  40.640 ms     55.360 ms   102 (trim 20%, outliers 4.9%)
firefox  Tiny      clean                 <= 0.19 ns        <= 1.23%   0.17..0.19 ns  -0.00..0.01 ns   0.17..0.19 ns    0.34  53.260 ms     53.910 ms   102 (trim 20%, outliers 9.8%)
safari   Hot       clean                 <= 5.32 ns       <= 19.57%   5.31..5.32 ns  -0.00..0.01 ns   5.31..5.32 ns    0.04  46.290 ms     55.340 ms   102 (trim 20%, outliers 4.9%)
safari   Tiny      clean                 <= 0.22 ns        <= 5.25%   0.21..0.22 ns  -0.00..0.00 ns   0.21..0.22 ns    0.28  58.020 ms     60.900 ms   102 (trim 20%, outliers 8.8%)

Raw samples are in the live-debugger-runtime-bench-results artifact.

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

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

@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@tyffical
tyffical requested a review from Scott-Meyer October 23, 2026 14:47
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T14:53:22.282288Z ff3d282 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Disabling the existing write guards leaves a symlink-write regression that can modify otherwise read-only project files.

2 open issues
What changed in this PR

Adds Node’s permission model to the fresh-process local backend executor introduced in #608, strengthening isolation while retaining fallback guards on unsupported Node configurations.

Changes:

  • Adds process-wide filesystem permissions and checks Vite module loads against readable roots and deny patterns.
  • Passes Vite filesystem settings and its SSR resolver to the executor.
  • Documents the sandbox and adds unit and integration coverage.
File Description
yarn.lock Records the picomatch dependencies.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childLaterImport.backend.ts Adds a missing-import fixture.
packages/​plugins/​apps/​src/​vite/​local-execution-sandbox.ts Implements permission flags, readable roots, and module-load checks.
packages/​plugins/​apps/​src/​vite/​local-execution-sandbox.test.ts Tests sandbox helpers and permission flags.
packages/​plugins/​apps/​src/​vite/​local-execution-protocol.ts Adds the fallback-guard setting.
packages/​plugins/​apps/​src/​vite/​local-execution-process.ts Applies permissions and guards module requests.
packages/​plugins/​apps/​src/​vite/​local-execution-process.integration.test.ts Covers sandbox restrictions and fallback behavior.
packages/​plugins/​apps/​src/​vite/​local-execution-child.ts Conditionally installs in-process guards.
packages/​plugins/​apps/​src/​vite/​index.ts Supplies Vite filesystem settings and resolution.
packages/​plugins/​apps/​README.md Documents sandbox behavior and compatibility.
packages/​plugins/​apps/​package.json Adds picomatch and its types.

Review effort: Balanced

Comment on lines +198 to +199
if (!config.installGuards) {
return load(config);

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.

Accepted as a known limitation: Node's permission model follows symlinks out of granted paths, as its docs note. Local execution is a parity aid, not a security boundary, so this is listed under Out of Scope and in the README instead of re-adding the JavaScript write guard.

Comment thread packages/plugins/apps/src/vite/local-execution-sandbox.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: ff3d2820fe

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

@tyffical
tyffical marked this pull request as ready for review October 23, 2026 16:15
@tyffical
tyffical requested review from a team as code owners October 23, 2026 16:15
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from ff3d282 to b42ad32 Compare October 23, 2026 16:17
@tyffical
tyffical removed this pull request from stack #618 October 26, 2026 20:54
@tyffical
tyffical added this pull request to stack #623 October 26, 2026 20:55
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch 2 times, most recently from 6c426e6 to e842184 Compare October 26, 2026 23:57
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from b42ad32 to 7dc7d64 Compare October 27, 2026 00:24
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from e842184 to 44a23e7 Compare October 27, 2026 00:35
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch 3 times, most recently from c89cc54 to 09ba149 Compare October 27, 2026 02:45
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 44a23e7 to 9f367ef Compare October 27, 2026 20:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from 09ba149 to 82157b9 Compare October 27, 2026 20:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 9f367ef to c78647b Compare October 28, 2026 05:39
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from 82157b9 to 1544220 Compare October 28, 2026 05:39
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from c78647b to b750a1b Compare October 28, 2026 16:12
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from 1544220 to bb0797f Compare October 28, 2026 16:12
Each local backend function call process now runs under Node's permission
model: it reads the project, its node_modules folders and linked packages,
Vite and server.fs.allow, writes only to its own temp dir and /tmp, and
can't spawn subprocesses, workers or native addons. Module loads over IPC
are checked against the readable folders and server.fs.deny before Vite
reads them, and a load error about an unreadable file is replaced with a
refusal that says whether the file matches server.fs.deny or is outside
the readable folders.

On a Node without the permission model, or without --allow-inspector while
debugging, the process keeps the in-process guards.

On a Node without module.registerHooks, such as Node 20, modules get no
import.meta.resolve: Vite resolves it on a worker thread there, which the
permission model refuses. The dev server warns once when a call runs that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot force-pushed the tiffany.trinh/apps-2961-child-permission-model branch from bb0797f to f820dcf Compare October 28, 2026 16:34
@tyffical
tyffical removed this pull request from stack #623 October 28, 2026 16:38
Base automatically changed from tiffany.trinh/apps-2961-child-process-execution to master October 28, 2026 16:58
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 49df3c8 into master Oct 28, 2026
9 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the tiffany.trinh/apps-2961-child-permission-model branch October 28, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants