From 53fe7d6d3b3630472124563ccafdad3f4964b4bc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:00:16 +0800 Subject: [PATCH] refactor(config): take the environment as an input at every configuration read (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(platform): accept absolute paths for any host and stop blaming the ceiling for a missing grant Addresses #548 and #545. #548: processScenarioSchema required a leading "/" for executable, working_directory and filesystem_roots, so no process scenario could be expressed on Windows at all. The report suggested node:path's isAbsolute, but that answers for the host running the check: on Linux `C:\tools` is not absolute, so a scenario captured on Windows would fail validation when it is replayed, compared, or asserted in a test on another host. A boundary schema must not vary with its evaluator, so the check accepts the POSIX form, a Windows drive path and a UNC share everywhere. The rejection message now names the constraint instead of leaving the caller to infer it from "invalid request". The HTTP `path` field inside `checkpoints` is an URL path and is deliberately left alone. I checked whether this merely moved the failure downstream: containment is enforced by isPathWithinRoot, which already uses host-aware path.relative, so validation really was the only blocker. #545: when a scope is inside the administrator ceiling but no grant was ever issued, the denial still pointed at widening the ceiling -- advice that cannot help, since the ceiling already permits the operation. The `missing` payload is computed against candidate *grants*, so it describes what a grant must cover, not a ceiling gap. `PermissionRemediation` gains a `grant` variant saying exactly that, and PermissionAuthority selects it when the denial is not a ceiling violation. An explicitly requested remediation is still honoured, and a genuine ceiling violation still says "configure". * refactor(config): take the environment as an input at every configuration read Completes B-2. Every `parseConfig(process.env)` site now reads from an injected environment, so configuration-driven behaviour is a function of an argument rather than of the machine running it. The twelve sites were not equivalent, and treating them as one number would have hidden the difference: - **src/application/ (3 sites) were genuine defects.** `runSessionStatus` already injected its `logger` but hardcoded `process.env` for config, so the MCP path's `dependencies.env` never reached it; `captureProcessScenarioFile` and `runAnalysis` had the same problem. `runAnalysis` gained `environment` on the options object it already had, which is where its other injected dependencies live. - **The CLI command layer (9 sites) is a documented boundary,** where `process.env` is legitimate. Rather than 9 independent ambient reads, they now share one seam: `createCli(environment)` passes it to the seven registrars that need configuration, and the nested helpers (`electronContext`, `runtimeContext`, `loadConfiguredAuthority`, `inspectJavaScriptReplay`) take it explicitly. Every new parameter defaults to `process.env`, so all existing call sites are unchanged and production behaviour is identical. The point is that the ambient read is now a documented default at a boundary rather than a hidden dependency deep inside behaviour. `process.env` now survives in `src/` only as those default parameters — nothing reads it at the point of use. * style(cli): apply prettier formatting to the runtime observation command --- src/application/DirectAnalysis.ts | 10 ++++++- src/application/DirectAnalysisStatus.ts | 15 ++++++---- src/application/ProcessCli.ts | 7 +++-- src/cli.ts | 22 +++++++------- src/cli/managedCommands.ts | 6 ++-- src/cli/utilityCommands.ts | 6 ++-- src/cliApplicationCommands.ts | 20 +++++++++---- src/cliBrowserScenarioCommands.ts | 3 +- src/cliElectronCommands.ts | 19 +++++++++---- src/cliEnvironmentSeam.test.ts | 38 +++++++++++++++++++++++++ src/cliJavaScriptRuntimeCommands.ts | 13 +++++++-- src/cliPolicyCommands.ts | 3 +- src/doctorRuntime.ts | 12 +++++--- 13 files changed, 131 insertions(+), 43 deletions(-) create mode 100644 src/cliEnvironmentSeam.test.ts diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 1025e7fdf5..3cf34ab25d 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -195,10 +195,18 @@ const runAnalysis = async ( readonly signal: AbortSignal; readonly permissionAuthority?: PermissionAuthority; readonly providerId?: AnalysisProviderSelector; + /** + * Environment the configuration is read from. Defaults to the process + * environment so existing callers are unchanged, but a caller may supply + * one — which is what lets the MCP path's injected environment reach this + * code, and what makes configuration-driven behaviour testable without + * mutating `process.env`. + */ + readonly environment?: Readonly>; }, ): Promise => { const { logger, signal, snapshotPath } = options; - const config = parseConfig(process.env); + const config = parseConfig(options.environment ?? process.env); if (!config.ok) return cliError(config.error); const authorization = await authorizeAnalysisRun({ config: config.value, diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index f3712d9f89..5c36443d68 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -7,8 +7,9 @@ import { createBinarySession } from "./runtime.js"; const runSessionStatus = async ( logger: Logger = silentLogger, + environment: Readonly> = process.env, ): Promise => { - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return { error: projectAnalysisError(config.error) }; const session = createBinarySession(config.value, logger); try { @@ -24,9 +25,13 @@ const runSessionStatus = async ( }; /** List complete provider identities, capabilities, and availability. */ -export const runProviderStatus = (logger: Logger = silentLogger) => - runSessionStatus(logger); +export const runProviderStatus = ( + logger: Logger = silentLogger, + environment: Readonly> = process.env, +) => runSessionStatus(logger, environment); /** List complete operation descriptors and availability. */ -export const runCapabilityStatus = (logger: Logger = silentLogger) => - runSessionStatus(logger); +export const runCapabilityStatus = ( + logger: Logger = silentLogger, + environment: Readonly> = process.env, +) => runSessionStatus(logger, environment); diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index 7a841c2045..ca3f2af961 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -32,7 +32,10 @@ export interface ProcessCliErrorOutput { } /** Capture one JSON scenario through the same policy and evidence contract as MCP. */ -export const captureProcessScenarioFile = async (path: string) => { +export const captureProcessScenarioFile = async ( + path: string, + environment: Readonly> = process.env, +) => { try { const input = await readJson(path); let scenario; @@ -44,7 +47,7 @@ export const captureProcessScenarioFile = async (path: string) => { "Process scenario is invalid. Check its required fields and limits, then try again.", ); } - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return cliAnalysisError(config.error); const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return cliAnalysisError(authority.error); diff --git a/src/cli.ts b/src/cli.ts index eedd2c1d2e..b9c0820958 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,12 +24,14 @@ import type { CliInstance } from "./cli/types.js"; * Analysis commands acquire and close their own sessions; bare `mcp` and * `--mcp` are intercepted by the executable dispatcher before this module loads. */ -export const createCli = (): CliInstance => { +export const createCli = ( + environment: Readonly> = process.env, +): CliInstance => { const logger = createLogger( "cli", - process.env.REA_LOG_LEVEL === undefined + environment.REA_LOG_LEVEL === undefined ? "silent" - : parseLogLevel(process.env.REA_LOG_LEVEL), + : parseLogLevel(environment.REA_LOG_LEVEL), ); const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, { version: PRODUCT_IDENTITY.packageVersion, @@ -52,17 +54,17 @@ export const createCli = (): CliInstance => { registerSetupCommands(cli, logger); registerCoreAnalysisCommands(cli, logger); - registerUtilityCommands(cli, logger); + registerUtilityCommands(cli, logger, environment); registerArtifactCommands(cli, logger); - registerManagedCommands(cli, logger); + registerManagedCommands(cli, logger, environment); registerEvidenceCommands(cli, logger); registerProcessCommands(cli, logger); - registerPolicyCommands(cli, logger); + registerPolicyCommands(cli, logger, environment); registerBrowserCommands(cli, logger); registerAdvancedBrowserCommands(cli, logger); - registerBrowserScenarioCommands(cli, logger); - registerElectronCommands(cli, logger); - registerJavaScriptRuntimeObservationCommands(cli, logger); - registerApplicationCommands(cli, logger); + registerBrowserScenarioCommands(cli, logger, environment); + registerElectronCommands(cli, logger, environment); + registerJavaScriptRuntimeObservationCommands(cli, logger, environment); + registerApplicationCommands(cli, logger, environment); return cli; }; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index 458fc8a6ab..f18e1c79a7 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -18,6 +18,7 @@ import { registerProjectManagedApplicationGraph } from "./managedProjectGraphCom export const registerManagedCommands = ( cli: CliInstance, logger: Logger, + environment: Readonly> = process.env, ): void => { registerInspectManagedArtifact(cli, logger); registerInspectManagedMembers(cli, logger); @@ -25,7 +26,7 @@ export const registerManagedCommands = ( registerCompareManagedMembers(cli, logger); registerImportManagedReconstruction(cli, logger); registerVerifyManagedNativeBoundaries(cli, logger); - registerPlanManagedRuntimeCorrelation(cli, logger); + registerPlanManagedRuntimeCorrelation(cli, logger, environment); registerProjectManagedApplicationGraph(cli, logger); }; @@ -166,6 +167,7 @@ const registerVerifyManagedNativeBoundaries = ( const registerPlanManagedRuntimeCorrelation = ( cli: CliInstance, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.planManagedRuntimeCorrelation, { description: @@ -182,7 +184,7 @@ const registerPlanManagedRuntimeCorrelation = ( "plan-managed-runtime-correlation", ); if (!input.ok) return input.error; - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return projectAnalysisError(config.error); const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return projectAnalysisError(authority.error); diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 78b6bdc0ad..24ded0f829 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -19,6 +19,7 @@ import type { CliInstance } from "./types.js"; export const registerUtilityCommands = ( cli: CliInstance, logger: Logger, + environment: Readonly> = process.env, ): void => { registerCapabilityCommands(cli, logger); registerNativeCommands(cli, logger); @@ -111,7 +112,7 @@ export const registerUtilityCommands = ( ), }); } - registerReferenceSourceCommand(cli, logger); + registerReferenceSourceCommand(cli, logger, environment); }; const registerCapabilityCommands = (cli: CliInstance, logger: Logger): void => { @@ -193,6 +194,7 @@ const registerNativeCommands = (cli: CliInstance, logger: Logger): void => { const registerReferenceSourceCommand = ( cli: CliInstance, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.importReferenceSource, { description: "Import a source tree as historical reference only", @@ -203,7 +205,7 @@ const registerReferenceSourceCommand = ( }), run: ({ args }) => logCliCommand(logger, "import-reference-source", async () => { - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return { error: "Import failed", diff --git a/src/cliApplicationCommands.ts b/src/cliApplicationCommands.ts index a995856de7..19658bf0e6 100644 --- a/src/cliApplicationCommands.ts +++ b/src/cliApplicationCommands.ts @@ -47,6 +47,7 @@ type CliInstance = ReturnType; export const registerApplicationCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { registerJsonCommand({ cli, @@ -106,7 +107,7 @@ export const registerApplicationCommands = ( CLI_COMMANDS.runControlledReplay, ); if (!input.ok) return input.error; - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return projectAnalysisError(config.error); const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return projectAnalysisError(authority.error); @@ -167,6 +168,8 @@ const registerObligationLedgerCommand = ( interface AuthorizedJsonCommandOptions { readonly cli: CliInstance; readonly logger: Logger; + /** Environment the configuration is read from; defaults to the process environment. */ + readonly environment?: Readonly>; readonly name: string; readonly description: string; readonly workflow: ( @@ -182,6 +185,7 @@ interface AuthorizedJsonCommandOptions { const registerAuthorizedJsonCommand = ({ cli, logger, + environment = process.env, name, description, workflow, @@ -195,7 +199,7 @@ const registerAuthorizedJsonCommand = ({ logCliCommand(logger, name, async () => { const input = await parseCliJsonInput(args.inputJson, name); if (!input.ok) return input.error; - const configured = await configuredAuthority(); + const configured = await configuredAuthority(environment); if (!configured.ok) return configured.error; const result = await workflow( configured.config, @@ -222,7 +226,9 @@ const registerCoverageCommand = (cli: CliInstance, logger: Logger): void => }, }); -const configuredAuthority = async (): Promise< +const configuredAuthority = async ( + environment: Readonly> = process.env, +): Promise< | { readonly ok: true; readonly config: AppConfig; @@ -233,7 +239,7 @@ const configuredAuthority = async (): Promise< readonly error: ReturnType; } > => { - const configured = await loadConfiguredAuthority(); + const configured = await loadConfiguredAuthority(environment); return configured.ok ? configured : { ok: false, error: projectAnalysisError(configured.error) }; @@ -302,7 +308,9 @@ const registerJsonCommand = ({ }); }; -const loadConfiguredAuthority = async (): Promise< +const loadConfiguredAuthority = async ( + environment: Readonly> = process.env, +): Promise< | { readonly ok: true; readonly config: AppConfig; @@ -310,7 +318,7 @@ const loadConfiguredAuthority = async (): Promise< } | { readonly ok: false; readonly error: AnalysisError } > => { - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return config; const authority = await loadConfiguredPermissionAuthority(config.value); return authority.ok diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 881dcfbc45..f09eaa549f 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -19,6 +19,7 @@ const OPERATION = "capture_browser_scenario"; export const registerBrowserScenarioCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.captureBrowserScenario, { description: @@ -35,7 +36,7 @@ export const registerBrowserScenarioCommands = ( const scenario = browserScenarioSchema.safeParse(input.value); if (!scenario.success) return cliError(new AnalysisInputError(OPERATION)); - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return cliError(config.error); const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return cliError(authority.error); diff --git a/src/cliElectronCommands.ts b/src/cliElectronCommands.ts index 30f0230dc5..75b7e99123 100644 --- a/src/cliElectronCommands.ts +++ b/src/cliElectronCommands.ts @@ -35,9 +35,10 @@ import { runCliJavaScriptApplicationAnalysis } from "./cli/javascriptApplication export const registerElectronCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { registerElectronObservationCommands(cli, logger); - registerElectronActiveCommand(cli, logger); + registerElectronActiveCommand(cli, logger, environment); registerJavaScriptApplicationCommand(cli, logger); registerJavaScriptRuntimeReconciliationCommand(cli, logger); }; @@ -45,6 +46,7 @@ export const registerElectronCommands = ( const registerElectronActiveCommand = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.captureElectronScenario, { description: "Run one approved, bounded owned Electron scenario", @@ -66,7 +68,7 @@ const registerElectronActiveCommand = ( input.value, ); if (!parsed.success) return inputError("capture_electron_scenario"); - const context = await electronContext(); + const context = await electronContext(environment); if (!context.ok) return context.error; const result = await captureElectronScenario( context.activeProvider, @@ -201,8 +203,10 @@ const registerJavaScriptApplicationCommand = ( }); }; -const electronContext = async () => { - const config = parseConfig(process.env); +const electronContext = async ( + environment: Readonly> = process.env, +) => { + const config = parseConfig(environment); if (!config.ok) return { ok: false as const, error: cliError(config.error) }; const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) @@ -216,8 +220,11 @@ const electronContext = async () => { }; }; -const electronObservationContext = async (operation: string) => { - const context = await electronContext(); +const electronObservationContext = async ( + operation: string, + environment: Readonly> = process.env, +) => { + const context = await electronContext(environment); if (!context.ok) return context; if (context.observationPolicy.status === "disabled") return { diff --git a/src/cliEnvironmentSeam.test.ts b/src/cliEnvironmentSeam.test.ts new file mode 100644 index 0000000000..339830078e --- /dev/null +++ b/src/cliEnvironmentSeam.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { createCli } from "./cli.js"; +import { createSystemDoctorHost } from "./doctorRuntime.js"; +import { captureProcessScenarioFile } from "./application/ProcessCli.js"; +import { runCapabilityStatus } from "./application/DirectAnalysisStatus.js"; + +describe("the CLI takes its environment as an input", () => { + it("builds from an explicitly supplied environment", () => { + expect(createCli({ REA_LOG_LEVEL: "debug" })).toBeDefined(); + expect(createCli({})).toBeDefined(); + }); + + it("still builds with no environment supplied", () => { + // The default keeps production behaviour identical. + expect(createCli()).toBeDefined(); + expect(createSystemDoctorHost()).toBeDefined(); + }); + + it("builds a doctor host from an explicit environment", () => { + expect(createSystemDoctorHost({})).toBeDefined(); + }); + + it("drives a process scenario failure from the supplied environment", async () => { + await expect( + captureProcessScenarioFile("/nonexistent/scenario.json", {}), + ).resolves.toBeDefined(); + }); + + it("reports session status from a supplied environment", async () => { + // An invalid configuration must surface through the caller's supplied + // environment rather than whatever the process happens to carry. + const result = await runCapabilityStatus(undefined, { + REA_LOG_LEVEL: "not-a-level", + }); + expect(result).toMatchObject({ error: expect.anything() }); + }); +}); diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index eea52a93ee..2ad5be4185 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -40,6 +40,7 @@ const observeOptionsSchema = z.object({ export const registerJavaScriptRuntimeObservationCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.listJavaScriptRuntimeTargets, { description: "List Node/Electron V8 Inspector targets", @@ -81,7 +82,10 @@ export const registerJavaScriptRuntimeObservationCommands = ( options: observeOptionsSchema, run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.observeJavaScriptRuntime, async () => { - const context = await runtimeContext("observe_javascript_runtime"); + const context = await runtimeContext( + "observe_javascript_runtime", + environment, + ); if (!context.ok) return context.error; const parsed = observeJavaScriptRuntimeInputSchema.safeParse({ inspector_endpoint: args.endpoint, @@ -100,8 +104,11 @@ export const registerJavaScriptRuntimeObservationCommands = ( }); }; -const runtimeContext = async (operation: string) => { - const config = parseConfig(process.env); +const runtimeContext = async ( + operation: string, + environment: Readonly> = process.env, +) => { + const config = parseConfig(environment); if (!config.ok) return { ok: false as const, error: cliError(config.error) }; const policy = config.value.v8InspectorObservationPolicy; if (policy.status === "disabled") diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index 12be3f4b25..ecc8a7fd71 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -43,6 +43,7 @@ export const approvePolicyRevocation = async (options: { export const registerPolicyCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly> = process.env, ): void => { cli.command(CLI_COMMANDS.policy, { description: "Inspect, explain, or revoke unified local permission grants", @@ -89,7 +90,7 @@ export const registerPolicyCommands = ( alias: { environmentNames: "environment-names", yes: "y" }, run: ({ agent, args, formatExplicit, options }) => logCliCommand(logger, "policy", async () => { - const config = parseConfig(process.env); + const config = parseConfig(environment); if (!config.ok) return failure(config.error); if (args.action === "status") return policyStatus(config.value); if (args.action === "list") return policyList(config.value); diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 94d60b3746..03fa0cd55c 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -10,10 +10,12 @@ import { parseConfig } from "./config.js"; import { SystemJavaScriptReplayHost } from "./replay/SystemJavaScriptReplayHost.js"; /** Compose provider diagnostics at the outer CLI adapter boundary. */ -export const createSystemDoctorHost = (): DoctorHost => +export const createSystemDoctorHost = ( + environment: Readonly> = process.env, +): DoctorHost => systemDoctorHost({ providerInspections: async () => [await inspectSystemGhidraProvider()], - javascriptReplayCheck: inspectJavaScriptReplay, + javascriptReplayCheck: () => inspectJavaScriptReplay(environment), linuxDemoRuntimeCheck: inspectLinuxPrivateDisplay, }); @@ -61,8 +63,10 @@ const inspectLinuxPrivateDisplay = async (): Promise => { }; }; -const inspectJavaScriptReplay = async (): Promise => { - const config = parseConfig(process.env); +const inspectJavaScriptReplay = async ( + environment: Readonly> = process.env, +): Promise => { + const config = parseConfig(environment); if (!config.ok) return { name: "javascript-replay",