From 6aefc10590621fe4672b6f3a0aa30e57a79d4796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 16:50:11 +0200 Subject: [PATCH] chore(gates): forbid process globals outside declared entry points (#3753) Co-Authored-By: Claude Opus 5.5 --- scripts/layering/check.ts | 6 +- .../layering/process-globals-policy.test.ts | 163 ++++++++++++ scripts/layering/process-globals-policy.ts | 235 ++++++++++++++++++ 3 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 scripts/layering/process-globals-policy.test.ts create mode 100644 scripts/layering/process-globals-policy.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 45bc916381..b805904e88 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -139,6 +139,7 @@ import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechani import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts'; import { checkDaemonPlatformRuntimeInventory } from './daemon-platform-runtime-inventory.ts'; import { checkDaemonClientEntry } from './daemon-client-entry.ts'; +import { processGlobalsViolations } from './process-globals-policy.ts'; import { checkDaemonProcessReach } from './daemon-process-reach.ts'; import { checkPackageExportSurface, @@ -453,7 +454,8 @@ function report( `concrete platform imports in every executable and type-only form; ` + `no production daemon file reaches the root platform-runtime family or a hub that ` + `does (R76); the daemon process reaches no caller-side replay source module over ` + - `static or dynamic edges (R82); ` + + `static or dynamic edges (R82); process globals stay at declared entries and ` + + `command-host reads stay at command-execution/spawn boundaries (R86); ` + `${packageExportSurfaceSummary(ratchets.testOnlyExports, reference.testOnlyExports)}; ` + `${testColocationSummary(ratchets.unmatchedTests, reference.unmatchedTests)}; and ` + `the handler-owned SessionState/SessionStore ` + @@ -539,6 +541,7 @@ export const LAYERING_RULE_IDS = [ 'provider-snapshot-presentation-ownership', 'snapshot-assembly-presentation-neutrality', 'daemon-platform-runtime-inventory', + 'process-globals', 'daemon-process-reach', 'daemon-client-entry', 'session-authority-overlay', @@ -607,6 +610,7 @@ export const LAYERING_RULES: Readonly> = { snapshotAssemblyPresentationViolations(context.sources, context.edges), 'daemon-platform-runtime-inventory': (context) => checkDaemonPlatformRuntimeInventory(context.edges), + 'process-globals': (context) => processGlobalsViolations(context.allTypeScriptSources), 'daemon-process-reach': (context) => checkDaemonProcessReach(context.sourceFiles, context.edges), 'daemon-client-entry': (context) => checkDaemonClientEntry(context.sourceFiles, context.edges), 'session-authority-overlay': (context) => diff --git a/scripts/layering/process-globals-policy.test.ts b/scripts/layering/process-globals-policy.test.ts new file mode 100644 index 0000000000..58dce092f9 --- /dev/null +++ b/scripts/layering/process-globals-policy.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { LAYERING_RULES, type LayeringContext } from './check.ts'; +import { PROCESS_GLOBALS_RULE, processGlobalsViolations } from './process-globals-policy.ts'; + +function check(source: string, file = 'src/commands/example.ts') { + return processGlobalsViolations(new Map([[file, source]])); +} + +test('the registered gate rejects process configuration and command-host scope in non-entry sources', () => { + const allTypeScriptSources = new Map([ + ['src/commands/example.ts', 'const env = process.env;\ncommandHostEnvironment();'], + ]); + const ratchets = { + typeInversions: {}, + largestTypeCycle: [], + sessionState: { writerOwnedFields: 0, ownerFileClaims: 0 }, + }; + const context: LayeringContext = { + sourceFiles: [...allTypeScriptSources.keys()], + sources: allTypeScriptSources, + allTypeScriptSources, + trackedSrcUtilsFiles: [], + edges: [], + ratchets, + reference: ratchets, + }; + const violations = LAYERING_RULES['process-globals'](context); + assert.deepEqual( + violations.map(({ rule, file, line }) => ({ rule, file, line })), + [1, 2].map((line) => ({ rule: PROCESS_GLOBALS_RULE, file: 'src/commands/example.ts', line })), + ); +}); + +test('all six process globals fail, including bracketed and imported process members', () => { + for (const member of ['env', 'cwd', 'on', 'once', 'exit', 'emitWarning']) { + for (const expression of [ + `process.${member}`, + `process['${member}']`, + `globalThis.process.${member}`, + ]) { + assert.equal(check(`${expression};`).length, 1, expression); + } + assert.equal(check(`import p from 'node:process'; p.${member};`).length, 1); + assert.equal(check(`import { ${member} as local } from 'process';`).length, 1); + assert.equal(check(`const { ${member}: local } = process;`).length, 1); + } +}); + +test('entry declarations permit process reads but do not permit ambient command-host calls', () => { + for (const file of [ + 'src/bin.ts', + 'src/cli.ts', + 'src/cli/commands/example.ts', + 'src/daemon.ts', + 'src/agent-device-client.ts', + 'packages/capture-kit/src/png-worker.ts', + 'packages/maestro/src/daemon-port/run-script-http-child.ts', + 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', + 'src/sdk/android-adb.ts', + 'src/io.ts', + ]) { + assert.deepEqual(check('process.env; process.on("SIGINT", stop);', file), [], file); + assert.equal(check('commandHostEnvironment();', file).length, 1, file); + } +}); + +test('command spawn boundaries permit the scoped host but still reject raw process configuration', () => { + for (const file of [ + 'packages/host-kit/src/internal/exec.ts', + 'packages/platform-apple/src/core/tool-provider.ts', + 'packages/platform-linux/src/tool-provider.ts', + 'src/platform-runtime-android-adb-host.ts', + ]) { + assert.deepEqual(check('commandHostEnvironment();', file), [], file); + assert.equal(check('process.env;', file).length, 1, file); + } +}); + +test('renamed and namespace command-host calls cannot bypass the boundary', () => { + assert.equal( + check( + "import { commandHostEnvironment as host } from '@agent-device/host-kit/command'; host();", + ).length, + 1, + ); + assert.equal(check('commands.commandHostEnvironment();').length, 1); +}); + +test('test support and scripts are outside the production rule', () => { + for (const file of [ + 'src/example.test.ts', + 'packages/test-kit/src/example.ts', + 'src/example.fixtures.ts', + 'packages/maestro/test/example.ts', + 'scripts/example.ts', + ]) { + assert.deepEqual(check('process.env; commandHostEnvironment();', file), [], file); + } + assert.equal(check('process.env;', 'packages/example/src/module.ts').length, 1); +}); + +test('comments, strings and explicit host values are not process reads', () => { + assert.deepEqual(check('// process.env\nconst text = "process.cwd()"; host.env; host.cwd;'), []); +}); + +test('bare process values are captures in calls, aliases, spreads, returns and computed access', () => { + for (const source of [ + 'captureHostEnvironment(process);', + 'const p = process; p.env;', + 'someFn(process);', + 'const p = { ...process };', + 'function capture() { return process; }', + 'process[member];', + 'captureHostEnvironment(globalThis.process);', + "import p from 'node:process'; someFn(p);", + "import { default as p } from 'node:process'; captureHostEnvironment(p);", + 'const { process: p } = globalThis; p.env;', + 'let p; ({process: p} = globalThis); captureHostEnvironment(p);', + "import * as p from 'node:process'; captureHostEnvironment(p.default);", + ]) { + assert.equal(check(source).length, 1, source); + assert.deepEqual(check(source, 'src/sdk/android-adb.ts'), [], source); + } +}); + +test('non-configuration process members and unrelated property names remain permitted', () => { + assert.deepEqual( + check( + 'process.platform; process.pid; process.stdout; object.process; const object = { process: true };', + ), + [], + ); + for (const file of [ + 'packages/platform-apple/src/os/macos/helper.ts', + 'packages/platform-apple/src/runner/runner-process-launch.ts', + ]) { + assert.equal(check('captureHostEnvironment(process);', file).length, 1, file); + } +}); + +test('local process bindings do not hide captures in other lexical scopes', () => { + assert.deepEqual( + check("import * as p from 'node:process'; function read(p) { return p.default; }"), + [], + ); + assert.deepEqual( + check('function read(process) { return process; } const object = { process: 1 };'), + [], + ); + assert.equal( + check('function read(process) { return process; } captureHostEnvironment(process);').length, + 1, + ); + assert.equal( + check('function read() { const process = child; return process; } const p = process;').length, + 1, + ); + assert.deepEqual( + check('type Record = { process: Child }; const child: typeof process = value;'), + [], + ); +}); diff --git a/scripts/layering/process-globals-policy.ts b/scripts/layering/process-globals-policy.ts new file mode 100644 index 0000000000..8f6ae88177 --- /dev/null +++ b/scripts/layering/process-globals-policy.ts @@ -0,0 +1,235 @@ +import { parseSync } from 'oxc-parser'; +import { asNode, memberName, nodeList, propertyName, visitAst } from './layering-ast.ts'; +import type { LayeringViolation } from './model.ts'; +import { isProductionSourceFile } from './tracked-sources.ts'; + +export const PROCESS_GLOBALS_RULE = 'R86 process-globals'; + +/** Process capture and lifecycle belong to these executable or released factory boundaries. */ +const PROCESS_ENTRIES = new Set([ + 'src/bin.ts', + 'src/cli.ts', + 'src/daemon.ts', + 'src/agent-device-client.ts', + 'packages/capture-kit/src/png-worker.ts', + 'packages/react-native/src/companion/companion-tunnel.ts', + 'packages/react-native/src/companion/client-companion-tunnel-worker.ts', + 'packages/maestro/src/daemon-port/run-script-http-child.ts', + // Released SDK defaults; #3759 removes these captures after agent-device/host ships (#3757). + 'src/io.ts', + 'src/sdk/remote-config.ts', + 'src/sdk/android-adb.ts', + 'src/sdk/metro.ts', + 'src/provider-limrun-runtime.ts', + 'src/mcp/command-tools.ts', + 'src/daemon-client/daemon-client.ts', + 'src/daemon-client/daemon-client-ensure.ts', + 'src/daemon-client/daemon-client-ensuring.ts', +]); +// src/cli/** and scripts/** are entry trees. Apple native helper executables live outside the +// TypeScript source trees. Reserve agent-device/host's entry here when #3757 supplies its path. + +/** Only command execution and spawn adapters may consult the ambient command-host scope. */ +const COMMAND_HOST_BOUNDARIES = new Set([ + 'packages/host-kit/src/internal/exec.ts', + 'packages/platform-apple/src/core/tool-provider.ts', + 'packages/platform-linux/src/tool-provider.ts', + 'src/platform-runtime-android-adb-host.ts', +]); +const PROCESS_MEMBERS = new Set(['env', 'cwd', 'on', 'once', 'exit', 'emitWarning']); + +/** + * Catches ambient configuration and process lifecycle in reusable modules, plus command-host + * scope reads outside spawn adapters. Uses the existing OXC layering parser; no debt baseline. + * Test support, including packages/test-kit, uses the shared production-source classification. + * Workers and children receive serializable configuration at their declared entry boundary. + * Kill criterion: retire only if reusable sources may own process configuration and lifecycle. + */ +export function processGlobalsViolations( + sources: ReadonlyMap, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const [file, source] of sources) { + if (!isProductionSourceFile(file)) continue; + const processEntry = PROCESS_ENTRIES.has(file) || file.startsWith('src/cli/'); + const commandBoundary = COMMAND_HOST_BOUNDARIES.has(file); + if (processEntry && commandBoundary) continue; + const program = parseSync(file, source).program; + const processNames = new Set(['process']); + const processNamespaces = new Set(); + const commandHostNames = new Set(['commandHostEnvironment']); + const processExports = new Set(); + visitAst(program, (node) => { + if (node.type !== 'ImportDeclaration') return; + const specifier = asNode(node.source)?.value; + for (const value of nodeList(node.specifiers)) { + const binding = asNode(value); + const local = asNode(binding?.local)?.name; + if (typeof local !== 'string') continue; + if (specifier === 'node:process' || specifier === 'process') { + if (binding?.type === 'ImportNamespaceSpecifier') processNamespaces.add(local); + if (binding?.type !== 'ImportSpecifier' || propertyName(binding.imported) === 'default') { + processNames.add(local); + } else if (PROCESS_MEMBERS.has(propertyName(binding.imported) ?? '')) { + processExports.add(local); + } + } + if (propertyName(binding?.imported) === 'commandHostEnvironment') { + commandHostNames.add(local); + } + } + }); + const parents = new Map>(); + visitAst(program, (node) => { + for (const value of Object.values(node)) { + for (const child of Array.isArray(value) ? value : [value]) { + const childNode = asNode(child); + if (childNode) parents.set(childNode, node); + } + } + }); + const scopes = new Set([ + 'Program', + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForOfStatement', + 'ForInStatement', + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', + ]); + const bindings = new Map>(); + const bindingNodes = new Set(); + const bind = (pattern: unknown, owner: Record | undefined): void => { + const node = asNode(pattern); + if (!node || !owner) return; + if (node.type === 'Identifier') { + bindingNodes.add(node); + const names = bindings.get(owner) ?? new Set(); + names.add(String(node.name)); + bindings.set(owner, names); + } else if (node.type === 'ObjectPattern') { + for (const property of nodeList(node.properties)) { + const item = asNode(property); + bind(item?.type === 'RestElement' ? item.argument : item?.value, owner); + } + } else if (node.type === 'ArrayPattern') { + for (const element of nodeList(node.elements)) bind(element, owner); + } else if (node.type === 'AssignmentPattern') bind(node.left, owner); + else if (node.type === 'RestElement') bind(node.argument, owner); + }; + const enclosingScope = (node: Record, functionOnly = false) => { + let parent = parents.get(node); + while ( + parent && + !( + scopes.has(String(parent.type)) && + (!functionOnly || parent.type === 'Program' || String(parent.type).includes('Function')) + ) + ) { + parent = parents.get(parent); + } + return parent; + }; + visitAst(program, (node) => { + if (node.type === 'VariableDeclarator') { + bind(node.id, enclosingScope(node, parents.get(node)?.kind === 'var')); + } else if (String(node.type).includes('Function')) { + for (const parameter of nodeList(node.params)) bind(parameter, node); + bind(node.id, node.type === 'FunctionDeclaration' ? enclosingScope(node) : node); + } else if (node.type === 'CatchClause') bind(node.param, node); + else if (node.type === 'ClassDeclaration') bind(node.id, enclosingScope(node)); + }); + const isProcess = (value: unknown): boolean => { + const node = asNode(value); + if (node?.type === 'Identifier') { + if (!processNames.has(String(node.name)) || bindingNodes.has(node)) return false; + let child = node; + for (let parent = parents.get(child); parent; parent = parents.get(child)) { + if (bindings.get(parent)?.has(String(node.name))) return false; + if (String(parent.type).startsWith('TS') && parent.expression !== child) return false; + child = parent; + } + return true; + } + if ( + node?.type === 'MemberExpression' && + processNamespaces.has(String(asNode(node.object)?.name)) && + isProcess(node.object) && + memberName(node) === 'default' + ) { + return true; + } + return ( + node?.type === 'MemberExpression' && + asNode(node.object)?.type === 'Identifier' && + ['globalThis', 'global'].includes(String(asNode(node.object)?.name)) && + memberName(node) === 'process' + ); + }; + visitAst(program, (node) => { + let message: string | undefined; + if (!processEntry) { + const parent = parents.get(node); + const bindingOrKey = + parent?.type === 'ImportDefaultSpecifier' || + parent?.type === 'ImportNamespaceSpecifier' || + parent?.type === 'ImportSpecifier' || + (parent?.type === 'VariableDeclarator' && parent.id === node) || + (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) || + (parent?.type === 'Property' && parent.key === node && !parent.computed) || + parent?.type === 'TSTypeQuery' || + parent?.type === 'TSTypeReference'; + const nonConfigurationMember = + parent?.type === 'MemberExpression' && + parent.object === node && + (!parent.computed || asNode(parent.property)?.type === 'Literal') && + memberName(parent) !== undefined && + !PROCESS_MEMBERS.has(memberName(parent)!); + if (isProcess(node) && !bindingOrKey && !nonConfigurationMember) { + message = + 'process capture belongs at a declared process entry; pass the owning host explicitly'; + } else if ( + (node.type === 'VariableDeclarator' || node.type === 'AssignmentExpression') && + ['globalThis', 'global'].includes(String(asNode(node.init ?? node.right)?.name)) && + asNode(node.id ?? node.left)?.type === 'ObjectPattern' && + nodeList(asNode(node.id ?? node.left)?.properties).some( + (value) => propertyName(asNode(value)?.key) === 'process', + ) + ) { + message = + 'process capture belongs at a declared process entry; pass the owning host explicitly'; + } else if (node.type === 'ImportDeclaration') { + if ( + nodeList(node.specifiers).some((value) => + processExports.has(String(asNode(asNode(value)?.local)?.name)), + ) + ) { + message = + 'process configuration and lifecycle imports belong at a declared process entry'; + } + } + } + if (!commandBoundary && node.type === 'CallExpression') { + const callee = asNode(node.callee); + if ( + (callee?.type === 'Identifier' && commandHostNames.has(String(callee.name))) || + (callee?.type === 'MemberExpression' && memberName(callee) === 'commandHostEnvironment') + ) { + message = + 'commandHostEnvironment() belongs at the command-execution/spawn boundary; pass the owning host explicitly'; + } + } + if (message) { + violations.push({ + rule: PROCESS_GLOBALS_RULE, + file, + line: source.slice(0, Number(node.start ?? 0)).split('\n').length, + message, + }); + } + }); + } + return violations; +}