Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ` +
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -607,6 +610,7 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
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) =>
Expand Down
163 changes: 163 additions & 0 deletions scripts/layering/process-globals-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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;'),
[],
);
});
Loading
Loading