Skip to content

[APPS-2947] Fix: fail the build on connection IDs read from objects that calls or aliases write to - #619

Draft
tyffical wants to merge 1 commit into
tiffany.trinh/apps-2947-debug-bundle-connection-idsfrom
tiffany.trinh/apps-2947-connection-ids-fail-closed
Draft

tyffical wants to merge 1 commit into
tiffany.trinh/apps-2947-debug-bundle-connection-idsfrom
tiffany.trinh/apps-2947-connection-ids-fail-closed

Conversation

@tyffical

@tyffical tyffical commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

  • APPS-2947: a backend function's allowed connection IDs come from a static read of its connectionId values, and they feed the server's allowlist and the Live Preview consent prompt.
  • When a call or an alias writes to the object a connectionId is read from, the static read reports the object's original value, not the one the call sends.
    • The build now fails with the existing unsupported-connectionId error instead of reporting a stale ID.
  • Stacked on [APPS-2947 Add: return connection IDs from debugBundle and attach the bundle to framed backend calls]([APPS-2947] Add: return connection IDs from debugBundle and attach the bundle to framed backend calls #566).

Architecture

flowchart LR
    S["connectionId: CONN.github"] --> R["Resolve CONN.github statically"]
    W["Writes to CONN or an alias of it: Object.assign, Reflect.set, gh = CONN.github; gh.x = ..."] --> M["Mutated-object set, one walk per extraction"]
    M --> R
    R -- "CONN not written" --> OK["Report the literal"]
    R -- "CONN written" --> ERR["Fail the build: mutated object binding"]
    O["Options key toJSON, then or __proto__"] --> ERR2["Fail the build: could hide a connectionId"]
Loading
  • The extractor already fails on a direct assignment, ++/--, delete or for…in/for…of write to the object; this adds mutator calls, including a template-literal or const-aliased method key, and the aliases a write can go through, across module exports.

Changes

7 changes across module-graph.ts, static-string-resolution.ts, extract-connection-ids-from-module-graph.ts, ambient-global-access.ts, type-guards.ts and tests
What changed File
Find the objects a mutator call writes to, and the alias sources of a written variable packages/plugins/apps/src/backend/ast-parsing/module-graph.ts
Mark an imported object as mutated when a mutator call or any alias of it writes to it, including aliases its module exports packages/plugins/apps/src/backend/ast-parsing/static-string-resolution.ts
Walk the module graph for writes once per extraction, only when a call has a connectionId packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts
Reject action options with a toJSON or then key, or a __proto__ key that isn't shorthand or null packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts
Export staticMemberName so mutator calls resolve method keys the way the ambient-global checks do packages/plugins/apps/src/backend/ast-parsing/ambient-global-access.ts
Add isNodeOfType for nodes typed against eslint-scope's copy of estree packages/plugins/apps/src/backend/ast-parsing/type-guards.ts
Cover each mutator, alias form and option key, the cases that still resolve, and one write walk per extraction packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.test.ts

QA Instructions

yarn build:all
# Expected: exit 0 ✅ VERIFIED

yarn test:unit packages/plugins/apps/src/backend/ast-parsing
# Expected: Tests: 301 passed, 301 total ✅ VERIFIED

TMPDIR=<a dir under /private/tmp> yarn test:unit packages/plugins/apps/src --testPathIgnorePatterns network-guard
# Expected: Test Suites: 41 passed; Tests: 946 passed ✅ VERIFIED (macOS needs a TMPDIR outside /var; CI on Linux doesn't)

yarn test:unit packages/plugins/apps/src/vite/network-guard.test.ts
# Expected: Tests: 129 passed, 129 total ✅ VERIFIED (run without TMPDIR set)

yarn lint && yarn typecheck:all && yarn cli integrity
# Expected: all exit 0, integrity leaves no extra changes ✅ VERIFIED
  • Production build: qa-fc-build.cjs below runs a real vite build of a one-function app per case, on this branch and on its parent.
W=<this checkout, after yarn build:all> S=<harness dir> node qa-fc-build.cjs
# Expected: "read only" builds with allowedConnectionIds [["conn-a"]]; Object.assign, the alias write, the toJSON option and both computed method keys fail the build; the two out-of-scope forms still build ✅ VERIFIED

W=<the parent PR's checkout, after yarn build:all> S=<harness dir> node qa-fc-build.cjs
# Expected (fail-before): every case builds and reports the stale ["conn-a"], though each mutating case sends conn-b ✅ VERIFIED
Production-build harness source and captured output
  • Layout: the harness directory holds qa-fc-build.cjs and node_modules/@datadog/action-catalog, copied from packages/tests/src/_jest/fixtures/apps_backend_project/node_modules; the script writes app/ and dist/ beside them.
// run: W=<build-plugins checkout, built> S=<harness dir> node qa-fc-build.cjs
// Runs a production `vite build` of a one-function app per case and prints whether it built.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { W, S } = process.env;
const { build } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);

const header = "import { sendSlackMessage } from '@datadog/action-catalog';\nconst CONN = { github: 'conn-a' };\n";
const call = "export async function run() {\n    return sendSlackMessage({ inputs: {}, connectionId: CONN.github });\n}\n";
const cases = {
    'read only': header + call,
    'Object.assign': header + "Object.assign(CONN, { github: 'conn-b' });\n" + call,
    'write through an alias': header + "const gh = CONN;\ngh.github = 'conn-b';\n" + call,
    'toJSON option': header + "export async function run() {\n    return sendSlackMessage({ inputs: {}, connectionId: 'conn-a', toJSON: () => ({}) });\n}\n",
    'aliased mutator (out of scope)': header + "const { assign } = Object;\nassign(CONN, { github: 'conn-b' });\n" + call,
    'template-literal key': header + "Object[`assign`](CONN, { github: 'conn-b' });\n" + call,
    'const-aliased computed key': header + "const method = 'assign';\nObject[method](CONN, { github: 'conn-b' });\n" + call,
    'spread before the target (out of scope)': header + "Object.assign(...[CONN, { github: 'conn-b' }]);\n" + call,
};

(async () => {
    for (const [name, source] of Object.entries(cases)) {
        const root = path.join(S, 'app');
        fs.rmSync(root, { recursive: true, force: true });
        fs.mkdirSync(path.join(root, 'backend'), { recursive: true });
        fs.cpSync(path.join(S, 'node_modules'), path.join(root, 'node_modules'), { recursive: true });
        fs.writeFileSync(path.join(root, 'index.html'), '<!doctype html><html><body><script type="module" src="/main.js"></script></body></html>\n');
        fs.writeFileSync(path.join(root, 'main.js'), "import { run } from './backend/conn.backend.ts';\nrun();\n");
        fs.writeFileSync(path.join(root, 'backend', 'conn.backend.ts'), source);
        try {
            await build({
                configFile: false,
                root,
                logLevel: 'silent',
                build: { outDir: path.join(S, 'dist'), emptyOutDir: true },
                plugins: [datadogVitePlugin({ apps: { enable: true }, logLevel: 'none' })],
            });
            const zip = path.join(S, 'dist', 'datadog-app-assets.zip');
            const manifest = JSON.parse(execFileSync('unzip', ['-p', zip, 'manifest.json'], { encoding: 'utf8' }));
            const ids = Object.values(manifest.backend.functions).map((f) => f.allowedConnectionIds);
            console.log(`${name}: built; manifest allowedConnectionIds ${JSON.stringify(ids)}`);
        } catch (error) {
            const message = String(error && error.message ? error.message : error).split('\n')[0];
            console.log(`${name}: build failed: ${message.replace(/ in \/\S+:/, ' in conn.backend.ts:').replace(/ in module '[^']+'/, '')}`);
        }
    }
})();
## this branch
read only: built; manifest allowedConnectionIds [["conn-a"]]
Object.assign: build failed: Unsupported action-catalog connectionId in conn.backend.ts: static string resolution static-definition-unsupported/unsupported-binding: Variable 'CONN' could not be resolved to a static definition: Variable 'CONN' has unsupported binding: mutated object binding..
write through an alias: build failed: Unsupported action-catalog connectionId in conn.backend.ts: static string resolution static-definition-unsupported/unsupported-binding: Variable 'CONN' could not be resolved to a static definition: Variable 'CONN' has unsupported binding: mutated object binding..
toJSON option: build failed: Unsupported action-catalog call in conn.backend.ts: a "toJSON" property could hide a connectionId.
aliased mutator (out of scope): built; manifest allowedConnectionIds [["conn-a"]]
template-literal key: build failed: Unsupported action-catalog connectionId in conn.backend.ts: static string resolution static-definition-unsupported/unsupported-binding: Variable 'CONN' could not be resolved to a static definition: Variable 'CONN' has unsupported binding: mutated object binding..
const-aliased computed key: build failed: Unsupported action-catalog connectionId in conn.backend.ts: static string resolution static-definition-unsupported/unsupported-binding: Variable 'CONN' could not be resolved to a static definition: Variable 'CONN' has unsupported binding: mutated object binding..
spread before the target (out of scope): built; manifest allowedConnectionIds [["conn-a"]]
## the parent PR's head
read only: built; manifest allowedConnectionIds [["conn-a"]]
Object.assign: built; manifest allowedConnectionIds [["conn-a"]]
write through an alias: built; manifest allowedConnectionIds [["conn-a"]]
toJSON option: built; manifest allowedConnectionIds [["conn-a"]]
aliased mutator (out of scope): built; manifest allowedConnectionIds [["conn-a"]]
template-literal key: built; manifest allowedConnectionIds [["conn-a"]]
const-aliased computed key: built; manifest allowedConnectionIds [["conn-a"]]
spread before the target (out of scope): built; manifest allowedConnectionIds [["conn-a"]]

Blast Radius

  • No feature flag. Risk: Medium: the extractor runs in every production vite build, so a build that passes today can now fail.
    • It fails only when a connectionId is read from an object that a mutator call or alias writes to, or the call's options carry a toJSON, then or non-inert __proto__ key.
    • The error names the file; an inline string connectionId builds.
  • A write to any member of the object fails the read, even a member the call doesn't use.
  • Objects that are only read, copied or passed to a method build as before.

Out of Scope / Follow-ups

9 items deferred
Item Status Next step
A spread that hides a mutator's target or Reflect.set's receiver, a non-const method key (Object[m]), or (Object?.assign)(…) Accepted: fails safe, since the server's allowlist refuses the connection ID the call sends None
Indirect or aliased mutators: (0, Object.assign)(…), .call/.apply, const { assign } = Object, globalThis.Object.*, __defineGetter__ Accepted: fails safe, for the same reason None
Writes through delete CONN?.x, an object-spread copy, Object.prototype, this, a function, return, container or awaited value, a default or namespace import, or a block-scoped exported var Accepted: fails safe, for the same reason None
Direct eval writes, e.g. eval("CONN.gh.id = 'x'"), which the extractor can't see, so it reports the stale ID Accepted: fails safe, for the same reason None
False positive: let cur = CONN.gh; cur = {...}; cur.x = 1 never writes CONN, but the build fails Accepted: fails closed, but an app using that copy pattern stops building Flow-sensitive alias tracking
Two parallel write walkers, one per module and one across imports, kept in step by hand Deferred: predates this PR Merge into one shared walker in module-graph.ts
getMutatedCallTargets and matchBulkCopyCall match Object/Reflect static calls separately Deferred Share one callee matcher
The cross-import walker keys its visited set by a ${isMember}:${isPastImport} string and re-resolves an import's definition per write Deferred: build-time cost only Use the two typed sets module-graph.ts uses, and cache definitions per import
Merge conflict with [APPS-2945 Add: warn on process.env reads in backend functions](#565) in module-graph.ts Open until one merges Take this PR's side and keep that PR's export on getModuleExportName

Documentation

🤖 Generated with Claude Code

…s or aliases

The connection ID extractor doesn't read a member of an object that a
known mutator writes to: Object.assign, defineProperty, defineProperties
and setPrototypeOf, and Reflect.set (its receiver, when one is given),
defineProperty, deleteProperty and setPrototypeOf, including one named
through a const-aliased computed key such as `Object[method]`. The same
holds when the object is written through an alias, such as `const gh =
CONN.gh`, `gh = CONN.gh`, `const gh = CONN?.gh || {}`, `cur ||= {}`, a
member of a rest copy (including one destructured from it), or a
`const`, `let` or destructured alias its module exports by name. Such a
connectionId takes the unsupported-connectionId path instead of
reporting a stale ID. Action options with a toJSON or then key, or a
__proto__ key other than a shorthand or null one, are rejected, since
each can change the connectionId the call sends. Each extraction walks
the module graph for writes at most once.

An object that is only read, logged, copied (including a write to a rest
copy's own properties) or used to call a method still resolves, as does
one whose alias is rebound without being written, or one passed to a
local function that shadows Object or Reflect. Writes through `this` in
the object's own methods, through a function, container or awaited value
the object is passed to, through a default or namespace import of it, or
through an aliased call to a mutator are not tracked; the server's
allowlist still refuses the resulting connection ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-connection-ids-fail-closed branch from e64de90 to 23be3cd Compare October 23, 2026 15:59
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.

1 participant