From 4e25927f72c902a83ad739a369c3f9abc9d4a600 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 21 Oct 2026 18:01:51 +0100 Subject: [PATCH] feat(run): control Bash environment forwarding and cancellation --- packages/cli/README.md | 34 ++ packages/cli/src/cli/cmd/run.ts | 11 +- packages/cli/src/headless.ts | 70 ++- packages/cli/src/index.ts | 92 ++-- packages/cli/src/shell/shell.ts | 7 +- packages/cli/src/tool/bash.ts | 110 ++++- packages/cli/src/util/bash-env-policy.ts | 169 +++++++ .../cli/test/cli/bash-env-forwarding.test.ts | 217 +++++++++ .../test/cli/bash-env-local-signal.test.ts | 273 +++++++++++ .../test/cli/bash-env-provider-signal.test.ts | 237 ++++++++++ .../cli/test/cli/bash-env-startup.test.ts | 359 +++++++++++++++ .../cli/test/cli/exception-handler.test.ts | 133 ++++-- .../cli/fixture/bash-env-forwarding-guard.ts | 41 ++ .../cli/test/cli/fixture/bash-env-startup.ts | 117 +++++ packages/cli/test/tool/bash-env.test.ts | 426 ++++++++++++++++++ .../cli/test/tool/fixture/bash-env-case.ts | 104 +++++ .../cli/test/util/bash-env-policy.test.ts | 139 ++++++ 17 files changed, 2414 insertions(+), 125 deletions(-) create mode 100644 packages/cli/src/util/bash-env-policy.ts create mode 100644 packages/cli/test/cli/bash-env-forwarding.test.ts create mode 100644 packages/cli/test/cli/bash-env-local-signal.test.ts create mode 100644 packages/cli/test/cli/bash-env-provider-signal.test.ts create mode 100644 packages/cli/test/cli/bash-env-startup.test.ts create mode 100644 packages/cli/test/cli/fixture/bash-env-forwarding-guard.ts create mode 100644 packages/cli/test/cli/fixture/bash-env-startup.ts create mode 100644 packages/cli/test/tool/bash-env.test.ts create mode 100644 packages/cli/test/tool/fixture/bash-env-case.ts create mode 100644 packages/cli/test/util/bash-env-policy.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 7cd9bf95..17fdc5ca 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -184,6 +184,40 @@ Provider failures and stream timeouts retain **1**; SIGINT/SIGTERM retain In headless mode, Aictrl automatically rejects permissions that would otherwise prompt a user. +### Bash environment forwarding (unreleased) + +These examples require a build containing this option; Windows execution is not +verified. Choose the exact environment names forwarded to the built-in `bash` +tool's shell and its path-resolution helper: + +```bash +aictrl run "Run the trusted task" # existing parent + hook inheritance +aictrl run --bash-env-allowlist '[]' "Run the trusted task" +aictrl run --bash-env-allowlist '["PATH","LANG"]' "Run the trusted task" +``` + +Omitting the option keeps existing inheritance. `[]` forwards no parent or hook +entries; a shell can still synthesize its own variables. Nothing automatically +adds `PATH`, `HOME`, proxy or CA settings. Missing selected names are omitted, +existing empty values remain empty, and duplicates are removed. POSIX names are +case-sensitive. Only the caller option sets this policy; project config and +`.env` cannot widen it. + +The helper receives selected parent values. After permission checks, the final +shell also considers the `shell.env` hook's overrides, then filters by name. +Opt-in hook records require valid environment names and string values without +NUL; invalid hook data fails that Bash launch. Invalid, missing or repeated +option values are configuration errors (exit **2**). Any explicit policy, +including `[]`, fails early with `--attach` because receiver enforcement is not +supported. + +This controls forwarding, not access to secrets: a command can still read +same-UID parent processes or credential files. `PATH` and `HOME` can grant +executable and file access. Trusted plugins, command-template shell expansion, +MCP, formatters and other bootstrap helpers retain separate parent authority. +Use separate credential phases and OS isolation; this option is not a sandbox +or a guarantee that an untrusted checkout is safe. + ### Skill Scope `aictrl run` lists project skills (`.aictrl/skill(s)/`, and `.claude/skills/`, diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 31f5a16f..a6f75797 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -16,6 +16,7 @@ import { Flag } from "../../flag/flag" import { HeadlessRun } from "../../flag/headless-run" import { bootstrap } from "../bootstrap" import { EOL } from "os" +import { AsyncLocalStorage } from "node:async_hooks" import { Filesystem } from "../../util/filesystem" import { createAictrlClient } from "@aictrl/sdk" import type { ToolPart } from "@aictrl/sdk/v2" @@ -377,6 +378,12 @@ export const RunCommand = cmd({ .strictOptions() .strictCommands(false) .parserConfiguration({ "populate--": true, "parse-positional-numbers": false }) + .option("bash-env-allowlist", { + type: "string", + nargs: 1, + requiresArg: true, + describe: "JSON array of exact environment variable names forwarded by native Bash", + }) .option("tools", { type: "string", nargs: 1, @@ -1265,9 +1272,7 @@ export const RunCommand = cmd({ }) return { data: session } }, - abort(opts: any) { - return SessionPrompt.cancel(opts.sessionID) - }, + abort: AsyncLocalStorage.bind((opts: { sessionID: string }) => SessionPrompt.cancel(opts.sessionID)), async prompt(opts: any) { promptResult = SessionPrompt.prompt({ sessionID: opts.sessionID, diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 094582b0..1d0d47e8 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -20,8 +20,14 @@ import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { Shutdown } from "./cli/shutdown" +import { BashEnvPolicy } from "./util/bash-env-policy" process.on("unhandledRejection", (e) => { + const formatted = BashEnvPolicy.format(e) + if (formatted) { + process.stderr.write(formatted + EOL) + process.exit(2) + } Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, stack: e instanceof Error ? e.stack : undefined, @@ -30,6 +36,11 @@ process.on("unhandledRejection", (e) => { }) process.on("uncaughtException", (e) => { + const formatted = BashEnvPolicy.format(e) + if (formatted) { + process.stderr.write(formatted + EOL) + process.exit(2) + } Log.Default.error("exception", { e: e instanceof Error ? e.message : e, stack: e instanceof Error ? e.stack : undefined, @@ -55,6 +66,7 @@ let cli = yargs(hideBin(process.argv)) choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) .middleware(async (opts) => { + BashEnvPolicy.initialize(opts) await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isLocal(), @@ -70,7 +82,7 @@ let cli = yargs(hideBin(process.argv)) Log.Default.info("aictrl-headless", { version: Installation.VERSION, - args: process.argv.slice(2), + args: BashEnvPolicy.explicit(opts) ? { command: "run", bashEnvPolicy: "allowlist" } : process.argv.slice(2), }) const storageDir = path.join(Global.Path.data, "storage") @@ -112,6 +124,12 @@ let cli = yargs(hideBin(process.argv)) cli = cli .fail((msg, err) => { + const missing = BashEnvPolicy.parserError(cli as typeof cli & BashEnvPolicy.Parser, msg ?? err?.message) + if (missing) throw missing + // Use yargs' actual parse result even when validation fails before middleware. + if (cli.parsed && BashEnvPolicy.explicit(cli.parsed.argv)) { + throw new BashEnvPolicy.ConfigError("Invalid run options; check --bash-env-allowlist and other run arguments.") + } if ( msg?.startsWith("Unknown argument") || msg?.startsWith("Not enough non-option arguments") || @@ -128,31 +146,37 @@ cli = cli try { await cli.parse() } catch (e) { - let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } + const policyError = BashEnvPolicy.format(e) + if (policyError) { + process.stderr.write(policyError + EOL) + process.exitCode = 2 + } else { + let data: Record = {} + if (e instanceof NamedError) { + const obj = e.toObject() + Object.assign(data, { + ...obj.data, + }) + } - if (e instanceof Error) { - Object.assign(data, { - name: e.name, - message: e.message, - cause: e.cause?.toString(), - stack: e.stack, - }) - } + if (e instanceof Error) { + Object.assign(data, { + name: e.name, + message: e.message, + cause: e.cause?.toString(), + stack: e.stack, + }) + } - Log.Default.error("fatal", data) - const formatted = FormatError(e) - if (formatted) UI.error(formatted) - if (formatted === undefined) { - UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) - process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL) + Log.Default.error("fatal", data) + const formatted = FormatError(e) + if (formatted) UI.error(formatted) + if (formatted === undefined) { + UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) + process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL) + } + process.exitCode = 1 } - process.exitCode = 1 } finally { await Shutdown.flush() process.exit() diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6cda1e1c..1b8ecbdc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,8 +28,14 @@ import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { Shutdown } from "./cli/shutdown" +import { BashEnvPolicy } from "./util/bash-env-policy" process.on("unhandledRejection", (e) => { + const formatted = BashEnvPolicy.format(e) + if (formatted) { + process.stderr.write(formatted + EOL) + process.exit(2) + } Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, stack: e instanceof Error ? e.stack : undefined, @@ -38,6 +44,11 @@ process.on("unhandledRejection", (e) => { }) process.on("uncaughtException", (e) => { + const formatted = BashEnvPolicy.format(e) + if (formatted) { + process.stderr.write(formatted + EOL) + process.exit(2) + } Log.Default.error("exception", { e: e instanceof Error ? e.message : e, stack: e instanceof Error ? e.stack : undefined, @@ -63,6 +74,7 @@ let cli = yargs(hideBin(process.argv)) choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) .middleware(async (opts) => { + BashEnvPolicy.initialize(opts) await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isLocal(), @@ -80,7 +92,7 @@ let cli = yargs(hideBin(process.argv)) Log.Default.info("aictrl", { version: Installation.VERSION, - args: process.argv.slice(2), + args: BashEnvPolicy.explicit(opts) ? { command: "run", bashEnvPolicy: "allowlist" } : process.argv.slice(2), }) const storageDir = path.join(Global.Path.data, "storage") @@ -154,6 +166,12 @@ let cli = yargs(hideBin(process.argv)) cli = cli .fail((msg, err) => { + const missing = BashEnvPolicy.parserError(cli as typeof cli & BashEnvPolicy.Parser, msg ?? err?.message) + if (missing) throw missing + // Use yargs' actual parse result even when validation fails before middleware. + if (cli.parsed && BashEnvPolicy.explicit(cli.parsed.argv)) { + throw new BashEnvPolicy.ConfigError("Invalid run options; check --bash-env-allowlist and other run arguments.") + } if ( msg?.startsWith("Unknown argument") || msg?.startsWith("Not enough non-option arguments") || @@ -170,42 +188,48 @@ cli = cli try { await cli.parse() } catch (e) { - let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } + const policyError = BashEnvPolicy.format(e) + if (policyError) { + process.stderr.write(policyError + EOL) + process.exitCode = 2 + } else { + let data: Record = {} + if (e instanceof NamedError) { + const obj = e.toObject() + Object.assign(data, { + ...obj.data, + }) + } - if (e instanceof Error) { - Object.assign(data, { - name: e.name, - message: e.message, - cause: e.cause?.toString(), - stack: e.stack, - }) - } + if (e instanceof Error) { + Object.assign(data, { + name: e.name, + message: e.message, + cause: e.cause?.toString(), + stack: e.stack, + }) + } - if (e instanceof ResolveMessage) { - Object.assign(data, { - name: e.name, - message: e.message, - code: e.code, - specifier: e.specifier, - referrer: e.referrer, - position: e.position, - importKind: e.importKind, - }) - } - Log.Default.error("fatal", data) - const formatted = FormatError(e) - if (formatted) UI.error(formatted) - if (formatted === undefined) { - UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) - process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL) + if (e instanceof ResolveMessage) { + Object.assign(data, { + name: e.name, + message: e.message, + code: e.code, + specifier: e.specifier, + referrer: e.referrer, + position: e.position, + importKind: e.importKind, + }) + } + Log.Default.error("fatal", data) + const formatted = FormatError(e) + if (formatted) UI.error(formatted) + if (formatted === undefined) { + UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) + process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL) + } + process.exitCode = 1 } - process.exitCode = 1 } finally { // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless diff --git a/packages/cli/src/shell/shell.ts b/packages/cli/src/shell/shell.ts index 96aac314..a8191533 100644 --- a/packages/cli/src/shell/shell.ts +++ b/packages/cli/src/shell/shell.ts @@ -2,12 +2,15 @@ import { Flag } from "@/flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" import path from "path" -import { spawn, type ChildProcess } from "child_process" +import { spawn } from "child_process" const SIGKILL_TIMEOUT_MS = 200 export namespace Shell { - export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { + export async function killTree( + proc: { pid?: number; kill(signal: "SIGTERM" | "SIGKILL"): unknown }, + opts?: { exited?: () => boolean }, + ): Promise { const pid = proc.pid if (!pid || opts?.exited?.()) return diff --git a/packages/cli/src/tool/bash.ts b/packages/cli/src/tool/bash.ts index 3005f38e..e1824b89 100644 --- a/packages/cli/src/tool/bash.ts +++ b/packages/cli/src/tool/bash.ts @@ -1,5 +1,9 @@ import z from "zod" import { spawn } from "child_process" +import { EventEmitter } from "node:events" +import { Readable } from "node:stream" +import type { ReadableStream as NodeReadableStream } from "node:stream/web" +import { finished } from "node:stream/promises" import { Tool } from "./tool" import { ToolGuidance } from "./guidance" import path from "path" @@ -18,6 +22,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" import { Plugin } from "@/plugin" +import { BashEnvPolicy } from "@/util/bash-env-policy" export const MAX_OUTPUT_BUFFER = 2 * 1024 * 1024 // 2MB, matching PTY module precedent const MAX_METADATA_LENGTH = 30_000 @@ -92,6 +97,58 @@ async function extractEmbeddedCommands( return commands } +// Bun's node:child_process envPairs conversion drops an own __proto__ name. +// The direct primitive preserves exact env entries; keep it scoped to opt-in. +function exact(command: string, shell: string, cwd: string, env: Record) { + const events = new EventEmitter<{ exit: []; error: [Error] }>() + const windows = process.platform === "win32" && /^(?:.*\\)?cmd(?:\.exe)?$/i.test(shell) + let code: number | null = null + const proc = Bun.spawn({ + cmd: windows ? [shell, "/d", "/s", "/c", `"${command}"`] : [shell, "-c", command], + windowsVerbatimArguments: windows, + cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: process.platform !== "win32", + onExit(_proc, exit, signal, error) { + // Bun may invoke onExit before spawn returns. Defer until adapters and + // completion listeners exist, as its node:child_process wrapper does. + process.nextTick(async () => { + // Match the existing Bun node:child_process signal/exit-code mapping. + code = typeof signal === "string" ? null : typeof signal === "number" ? 0 : exit + try { + await Promise.all([finished(stdout, { cleanup: true }), finished(stderr, { cleanup: true })]) + } catch (error) { + events.emit("error", error instanceof Error ? error : new Error("Bash output stream failed.")) + return + } + if (error) { + events.emit("error", error) + return + } + events.emit("exit") + }) + }, + }) + // Bun and Node augment their Web-stream declarations differently. + const stdout = Readable.fromWeb(proc.stdout as unknown as NodeReadableStream) + const stderr = Readable.fromWeb(proc.stderr as unknown as NodeReadableStream) + return { + pid: proc.pid, + stdout, + stderr, + get exitCode() { + return code + }, + kill(signal: "SIGTERM" | "SIGKILL") { + proc.kill(signal) + }, + once: events.once.bind(events), + } +} + // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() @@ -114,6 +171,10 @@ export const BashTool = Tool.define("bash", async () => { description: z.string().describe("Short summary of what this command does, in 5-10 words"), }), async execute(params, ctx) { + const policy = BashEnvPolicy.current() + const platform = process.platform === "win32" ? "win32" : "posix" + const environment = + policy.mode === "allowlist" ? BashEnvPolicy.select(policy, process.env, {}, platform) : undefined const cwd = params.workdir || Instance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) @@ -141,8 +202,9 @@ export const BashTool = Tool.define("bash", async () => { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue - const resolved = await $`realpath ${arg}` - .cwd(cwd) + const preflight = $`realpath ${arg}`.cwd(cwd) + if (environment) preflight.env(environment) + const resolved = await preflight .quiet() .nothrow() .text() @@ -293,16 +355,19 @@ export const BashTool = Tool.define("bash", async () => { { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) - const proc = spawn(params.command, { - shell, - cwd, - env: { - ...process.env, - ...shellEnv.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }) + const proc = + policy.mode === "allowlist" + ? exact(params.command, shell, cwd, BashEnvPolicy.select(policy, process.env, shellEnv.env, platform)) + : spawn(params.command, { + shell, + cwd, + env: { + ...process.env, + ...shellEnv.env, + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) let output = "" let truncatedBytes = 0 @@ -343,24 +408,16 @@ export const BashTool = Tool.define("bash", async () => { const kill = () => Shell.killTree(proc, { exited: () => exited }) - if (ctx.abort.aborted) { - aborted = true - await kill() - } - const abortHandler = () => { aborted = true void kill() } - ctx.abort.addEventListener("abort", abortHandler, { once: true }) - - const timeoutTimer = setTimeout(() => { - timedOut = true - void kill() - }, timeout + 100) - await new Promise((resolve, reject) => { + const timeoutTimer = setTimeout(() => { + timedOut = true + void kill() + }, timeout + 100) const cleanup = () => { clearTimeout(timeoutTimer) ctx.abort.removeEventListener("abort", abortHandler) @@ -377,6 +434,11 @@ export const BashTool = Tool.define("bash", async () => { cleanup() reject(error) }) + + // Register completion before killing even an already-aborted child: + // killTree awaits escalation, during which an exit can otherwise be lost. + ctx.abort.addEventListener("abort", abortHandler, { once: true }) + if (ctx.abort.aborted) abortHandler() }) if (truncatedBytes > 0) { diff --git a/packages/cli/src/util/bash-env-policy.ts b/packages/cli/src/util/bash-env-policy.ts new file mode 100644 index 00000000..87d3aac8 --- /dev/null +++ b/packages/cli/src/util/bash-env-policy.ts @@ -0,0 +1,169 @@ +/** Process-launch policy for the native Bash implementation, never project configuration. */ +export namespace BashEnvPolicy { + export type Policy = Readonly<{ mode: "inherit" }> | Readonly<{ mode: "allowlist"; names: readonly string[] }> + + // yargs exposes these runtime parser details; @types/yargs omits getOptions. + export type Parser = { + getOptions(): { __(message: string, value: string): string } + parsed?: false | { aliases: Record } + } + + export class ConfigError extends Error { + readonly code = "BASH_ENV_CONFIGURATION" + + constructor( + message: string, + readonly field = "bash-env-allowlist", + readonly index?: number, + ) { + super(message) + this.name = "BashEnvConfigurationError" + } + } + + const inheritance: Policy = Object.freeze({ mode: "inherit" }) + let policy: Policy | undefined + + export function explicit(options: Record) { + return Object.hasOwn(options, "bash-env-allowlist") || Object.hasOwn(options, "bashEnvAllowlist") + } + + /** Pure parsing: never reads argv, environment, configuration or process state. */ + export function parse(input: unknown, platform: "posix" | "win32" = "posix"): Policy { + if (input === undefined) return inheritance + if (typeof input !== "string") { + throw new ConfigError("Use --bash-env-allowlist once with one JSON array of variable names.") + } + const value: unknown = (() => { + try { + return JSON.parse(input) + } catch { + // JSON errors include supplied text on some runtimes. Do not retain their cause. + throw new ConfigError("--bash-env-allowlist must be a JSON array of variable names.") + } + })() + if (!Array.isArray(value)) { + throw new ConfigError("--bash-env-allowlist must be a JSON array of variable names.") + } + const names: string[] = [] + const seen = new Set() + for (const [index, name] of value.entries()) { + if (typeof name !== "string" || !name || /[\0=*?\[\]{}]/u.test(name)) { + throw new ConfigError( + "--bash-env-allowlist contains an invalid variable name at item " + index + ".", + undefined, + index, + ) + } + const identity = platform === "win32" ? name.toUpperCase() : name + if (seen.has(identity)) continue + seen.add(identity) + names.push(name) + } + return Object.freeze({ mode: "allowlist", names: Object.freeze(names) }) + } + + /** Call before Instance/bootstrap, using the actual parsed CLI options or trusted host input. */ + export function initialize(options: Record): Policy { + const present = explicit(options) + if (present && Object.hasOwn(options, "attach")) { + throw new ConfigError("--bash-env-allowlist is not supported with --attach.", "attach") + } + const input = Object.hasOwn(options, "bash-env-allowlist") + ? options["bash-env-allowlist"] + : options.bashEnvAllowlist + if (present && input === undefined) { + throw new ConfigError("--bash-env-allowlist requires one JSON array of variable names.") + } + const next = parse(input, process.platform === "win32" ? "win32" : "posix") + if (policy && JSON.stringify(policy) !== JSON.stringify(next)) { + throw new ConfigError("The Bash environment launch policy is already initialized.") + } + policy ??= next + return policy + } + + /** Ordinary in-process hosts that never initialize retain inheritance. */ + export function current(): Policy { + return policy ?? inheritance + } + + /** Select an exact native environment; never mutate or implicitly extend the parent. */ + export function select( + launch: Extract, + parent: Record, + overrides: unknown = {}, + platform: "posix" | "win32" = "posix", + ): Record { + if ( + typeof overrides !== "object" || + overrides === null || + (Object.getPrototypeOf(overrides) !== Object.prototype && Object.getPrototypeOf(overrides) !== null) + ) { + throw new ConfigError("The shell.env hook must return an environment record.", "shell.env") + } + const entries = Reflect.ownKeys(overrides) + .map((key, index) => { + if (typeof key !== "string" || !key || /[\0=]/u.test(key)) { + throw new ConfigError( + "The shell.env hook contains an invalid environment name at item " + index + ".", + "shell.env", + index, + ) + } + const value: unknown = (overrides as Record)[key] + if (typeof value !== "string" || value.includes("\0")) { + throw new ConfigError( + "The shell.env hook contains an invalid environment value at item " + index + ".", + "shell.env", + index, + ) + } + return [key, value] as const + }) + .filter(([key]) => Object.prototype.propertyIsEnumerable.call(overrides, key)) + const identity = (name: string) => (platform === "win32" ? name.toUpperCase() : name) + const selected = new Set(launch.names.map(identity)) + const values = new Map() + // Node uses the first lexicographic key for case-colliding Windows env names. + for (const key of Object.keys(parent).sort()) { + const name = identity(key) + if (!selected.has(name) || values.has(name)) continue + values.set(name, [key, parent[key]]) + } + const hooks = new Set() + for (const entry of entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + const name = identity(entry[0]) + if (!selected.has(name) || hooks.has(name)) continue + hooks.add(name) + values.set(name, entry) + } + const result: Record = Object.create(null) + for (const [index, [key, value]] of [...values.values()].entries()) { + if (value === undefined) continue + if (typeof value !== "string" || value.includes("\0")) { + throw new ConfigError( + "A selected parent environment value is invalid at item " + index + ".", + "parent.env", + index, + ) + } + result[key] = value + } + return result + } + + export function parserError(parser: Parser, message: unknown) { + const key = "bash-env-allowlist" + const names = [key, ...(parser.parsed ? (parser.parsed.aliases[key] ?? []) : [])] + // Compare the owned option's diagnostic using this actual parser's locale. + // Missing nargs values have no property in parsed.argv; never scan raw argv. + if (names.some((name) => message === parser.getOptions().__("Not enough arguments following: %s", name))) { + return new ConfigError("--bash-env-allowlist requires one JSON array of variable names.") + } + } + + export function format(error: unknown) { + if (error instanceof ConfigError) return error.code + ": " + error.message + } +} diff --git a/packages/cli/test/cli/bash-env-forwarding.test.ts b/packages/cli/test/cli/bash-env-forwarding.test.ts new file mode 100644 index 00000000..82980c1e --- /dev/null +++ b/packages/cli/test/cli/bash-env-forwarding.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { spawn } from "node:child_process" + +for (const entry of ["index", "headless"] as const) + for (const credential of ["present", "absent"] as const) + test(`${entry}: ${credential} parent credential controls authenticated provider across actual Bash dispatch`, async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-bash-env-forwarding-")) + const requests: { body: Record; auth: string | null }[] = [] + const ledger = path.join(cwd, "effects.jsonl") + await fs.writeFile(ledger, "") + const network = path.join(cwd, "network.jsonl") + await fs.writeFile(network, "") + const helper = path.join(cwd, "child.ts") + await fs.writeFile(helper, `console.log(JSON.stringify({env:process.env,cwd:process.cwd()}))`) + await fs.writeFile(path.join(cwd, "target"), "owned") + const plugin = path.join(cwd, "plugin.ts") + await fs.writeFile( + plugin, + `import{appendFileSync}from"node:fs";export default async()=>({"shell.env":async(_input,output)=>{appendFileSync(${JSON.stringify(ledger)},JSON.stringify({kind:"hook",parentKey:process.env.CUSTOM_GATEWAY_KEY,gh:process.env.GH_TOKEN})+"\\n");output.env={ALLOWED:"hook-override",HOOK_SECRET:"fake-hook-secret"};process.env.AICTRL_BASH_ENV_ALLOWLIST='["GH_TOKEN"]'}})`, + ) + const command = `printf '%s\\n' 'AICTRL_BASH_ENV_ALLOWLIST=["GH_TOKEN","CUSTOM_GATEWAY_KEY"]' > .env; cat target >/dev/null; ${JSON.stringify(process.execPath)} ${JSON.stringify(helper)}` + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (new URL(request.url).pathname !== "/v1/chat/completions") + return new Response("unexpected", { status: 400 }) + requests.push({ + body: (await request.json()) as Record, + auth: request.headers.get("authorization"), + }) + if (request.headers.get("authorization") !== "Bearer fake-model") + return new Response(JSON.stringify({ error: { message: "Owned fake credential rejected" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + const delta = + requests.length === 1 + ? { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_owned", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command, description: "Owned environment probe" }), + }, + }, + ], + } + : { role: "assistant", content: "Done" } + const chunks = [ + { choices: [{ index: 0, delta, finish_reason: null }] }, + { + choices: [{ index: 0, delta: {}, finish_reason: requests.length === 1 ? "tool_calls" : "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ] + return new Response(chunks.map((v) => `data: ${JSON.stringify(v)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }) + }, + }) + await fs.writeFile( + path.join(cwd, "aictrl.json"), + JSON.stringify({ + plugin: [plugin], + enabled_providers: ["fixture"], + provider: { + fixture: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "{env:AICTRL_TEST_PROVIDER_KEY}", baseURL: server.url.origin + "/v1" }, + models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } }, + }, + }, + agent: { title: { disable: true } }, + }), + ) + // Actual Bun dotenv supplies a wider legacy key; explicit parsed policy must remain authoritative. + await fs.writeFile(path.join(cwd, ".env"), 'AICTRL_BASH_ENV_ALLOWLIST=["GH_TOKEN","CUSTOM_GATEWAY_KEY"]\n') + const env: Record = { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(":"), + HOME: path.join(cwd, "home"), + AICTRL_TEST_HOME: path.join(cwd, "home"), + SHELL: "/bin/bash", + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(cwd, "managed"), + XDG_DATA_HOME: path.join(cwd, "data"), + XDG_CONFIG_HOME: path.join(cwd, "config"), + XDG_CACHE_HOME: path.join(cwd, "cache"), + XDG_STATE_HOME: path.join(cwd, "state"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(cwd, "bun-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + GH_TOKEN: "fake-gh", + CUSTOM_GATEWAY_KEY: "fake-model", + AICTRL_TEST_PROVIDER_KEY: "fake-model", + ALLOWED: "parent-allowed", + BASH_ENV_NETWORK_LEDGER: network, + BASH_ENV_PROVIDER_ORIGIN: server.url.origin, + TZ: "UTC", + } + if (credential === "absent") delete env.AICTRL_TEST_PROVIDER_KEY + const args = [ + "run", + "--no-install", + "--preload", + path.join(import.meta.dir, "fixture/bash-env-forwarding-guard.ts"), + "--conditions=browser", + path.resolve(import.meta.dir, `../../src/${entry}.ts`), + "run", + "--bash-env-allowlist", + '["PATH","ALLOWED"]', + "--tools", + "bash", + "--format", + "json", + "--model", + "fixture/fixture", + "Use the owned Bash tool once, then finish.", + ] + const child = spawn(process.execPath, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "", + stderr = "", + deadline = false + child.stdout!.on("data", (v) => (stdout += v)) + child.stderr!.on("data", (v) => (stderr += v)) + const exists = () => { + try { + process.kill(-child.pid!, 0) + return true + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ESRCH") return false + throw e + } + } + const timer = setTimeout(() => { + deadline = true + if (exists()) process.kill(-child.pid!, "SIGTERM") + }, 18000) + const escalation = setTimeout(() => { + if (exists()) process.kill(-child.pid!, "SIGKILL") + }, 18200) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => { + clearTimeout(timer) + clearTimeout(escalation) + server.stop(true) + }) + const effects = (await fs.readFile(ledger, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const attempts = (await fs.readFile(network, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const events = stdout + .split("\n") + .filter((v) => v.startsWith("{")) + .map((v) => JSON.parse(v)) + const result = { + entry, + cwd, + launch: { args, env }, + code, + deadline, + groupGone: !exists(), + stdout, + stderr, + requests, + effects, + attempts, + events, + } + if (process.env.BASH_ENV_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join(process.env.BASH_ENV_EVIDENCE, `${entry}-${credential}-provider-continuity.json`), + JSON.stringify(result, null, 2) + "\n", + ) + } + expect(deadline, JSON.stringify(result)).toBe(false) + if (credential === "absent") { + expect(code, stderr + stdout).toBe(1) + expect(requests.length).toBeGreaterThan(0) + expect(requests.every((r) => r.auth !== "Bearer fake-model")).toBe(true) + expect(effects).toEqual([]) + expect(attempts.some((r) => !r.allowed)).toBe(false) + return + } + expect(code, stderr + stdout).toBe(0) + expect(result.groupGone).toBe(true) + expect(requests).toHaveLength(2) + expect(requests.map((r) => r.auth)).toEqual(["Bearer fake-model", "Bearer fake-model"]) + expect(effects).toEqual([{ kind: "hook", parentKey: "fake-model", gh: "fake-gh" }]) + expect(attempts.some((r) => !r.allowed)).toBe(false) + expect(attempts.map((r) => [r.parentProviderKey, r.parentGh])).toEqual([ + ["fake-model", "fake-gh"], + ["fake-model", "fake-gh"], + ]) + const messages = requests[1].body.messages as { role: string; content: string }[] + const tool = messages.find((m) => m.role === "tool")! + expect(tool).toBeDefined() + const output = JSON.parse(tool.content) + expect(output.env.ALLOWED).toBe("hook-override") + for (const name of ["GH_TOKEN", "CUSTOM_GATEWAY_KEY", "AICTRL_TEST_PROVIDER_KEY", "HOOK_SECRET", "HOME"]) + expect(output.env).not.toHaveProperty(name) + }) diff --git a/packages/cli/test/cli/bash-env-local-signal.test.ts b/packages/cli/test/cli/bash-env-local-signal.test.ts new file mode 100644 index 00000000..c073a458 --- /dev/null +++ b/packages/cli/test/cli/bash-env-local-signal.test.ts @@ -0,0 +1,273 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" + +type Input = { + entry: "index" | "headless" + signal?: "SIGINT" | "SIGTERM" + policy: "inherit" | "empty" | "selected" + control?: boolean +} +async function state(pid: number) { + try { + const raw = await fs.readFile(`/proc/${pid}/stat`, "utf8") + const parts = raw.slice(raw.lastIndexOf(")") + 2).split(" ") + return { pid, exists: true, state: parts[0], ppid: Number(parts[1]), pgid: Number(parts[2]) } + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return { pid, exists: false } + throw e + } +} +async function run(input: Input) { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-local-signal-")) + const ledger = path.join(cwd, "effects.jsonl"), + network = path.join(cwd, "network.jsonl"), + ready = path.join(cwd, "grand.ready") + await fs.writeFile(ledger, "") + await fs.writeFile(network, "") + const helper = path.join(cwd, "lifecycle.ts") + const grand = `import fs from"node:fs";fs.writeFileSync(${JSON.stringify(ready)},"ready");setInterval(()=>{},1000)` + await fs.writeFile( + helper, + `import fs from"node:fs";const record=v=>fs.appendFileSync(${JSON.stringify(ledger)},JSON.stringify(v)+"\\n");const child=Bun.spawn([${JSON.stringify(process.execPath)},"--no-env-file","-e",${JSON.stringify(grand)}],{env:{PATH:"/usr/bin:/bin",HOME:${JSON.stringify(path.join(cwd, "grand-home"))}},stdin:"ignore",stdout:"ignore",stderr:"ignore"});record({kind:"start",pid:process.pid,grandchild:child.pid});process.on("SIGTERM",async()=>{child.kill("SIGTERM");await child.exited;record({kind:"reaped"});process.exit(0)});while(!fs.existsSync(${JSON.stringify(ready)}))await Bun.sleep(5);record({kind:"ready",pid:process.pid,grandchild:child.pid,grandReady:true});console.log("OWNED_READY");setInterval(()=>{},1000)`, + ) + const command = input.control + ? "printf completed" + : `exec ${JSON.stringify(process.execPath)} --no-env-file ${JSON.stringify(helper)}` + const requests: { auth: string | null; body: unknown }[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (new URL(request.url).pathname !== "/v1/chat/completions") + return new Response("unexpected owned path", { status: 400 }) + requests.push({ auth: request.headers.get("authorization"), body: await request.json() }) + if (request.headers.get("authorization") !== "Bearer fake-local-model") + return new Response("owned unauthorized", { status: 401 }) + const delta = + requests.length === 1 + ? { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_owned", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command, description: "Owned local signal probe" }), + }, + }, + ], + } + : { role: "assistant", content: "Done" } + return new Response( + [ + { choices: [{ index: 0, delta, finish_reason: null }] }, + { + choices: [{ index: 0, delta: {}, finish_reason: requests.length === 1 ? "tool_calls" : "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ] + .map((x) => `data: ${JSON.stringify(x)}\n\n`) + .join("") + "data: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + await fs.writeFile( + path.join(cwd, "aictrl.json"), + JSON.stringify({ + enabled_providers: ["fixture"], + provider: { + fixture: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "{env:AICTRL_LOCAL_SIGNAL_KEY}", baseURL: server.url.origin + "/v1" }, + models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } }, + }, + }, + agent: { title: { disable: true } }, + }), + ) + const env = { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(":"), + HOME: path.join(cwd, "home"), + AICTRL_TEST_HOME: path.join(cwd, "home"), + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(cwd, "managed"), + XDG_DATA_HOME: path.join(cwd, "data"), + XDG_CONFIG_HOME: path.join(cwd, "config"), + XDG_CACHE_HOME: path.join(cwd, "cache"), + XDG_STATE_HOME: path.join(cwd, "state"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(cwd, "bun-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + SHELL: "/bin/bash", + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + AICTRL_LOCAL_SIGNAL_KEY: "fake-local-model", + GH_TOKEN: "fake-gh", + BASH_ENV_NETWORK_LEDGER: network, + BASH_ENV_PROVIDER_ORIGIN: server.url.origin, + TZ: "UTC", + } + const args = [ + "run", + "--no-install", + "--no-env-file", + "--preload", + path.join(import.meta.dir, "fixture/bash-env-forwarding-guard.ts"), + "--conditions=browser", + path.resolve(import.meta.dir, `../../src/${input.entry}.ts`), + "run", + ...(input.policy === "inherit" ? [] : ["--bash-env-allowlist", input.policy === "empty" ? "[]" : '["PATH"]']), + "--tools", + "bash", + "--format", + "json", + "--model", + "fixture/fixture", + "Run the owned Bash probe once, then finish.", + ] + const child = spawn(process.execPath, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "", + stderr = "", + deadline = false, + sentAt: number | undefined, + signalReceipt: unknown + child.stdout!.on("data", (v) => (stdout += v)) + child.stderr!.on("data", (v) => (stderr += v)) + const effects = async () => + (await fs.readFile(ledger, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const groupExists = () => { + try { + process.kill(-child.pid!, 0) + return true + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ESRCH") return false + throw e + } + } + const poll = setInterval(async () => { + const rows = await effects() + if (!input.control && sentAt === undefined && rows.some((e) => e.kind === "ready" && e.grandReady)) { + sentAt = Date.now() + signalReceipt = { at: sentAt, pid: child.pid, signal: input.signal, readiness: rows } + child.kill(input.signal!) + } + }, 10) + const timer = setTimeout(() => { + deadline = true + if (groupExists()) process.kill(-child.pid!, "SIGTERM") + }, 8500) + const escalation = setTimeout(() => { + if (groupExists()) process.kill(-child.pid!, "SIGKILL") + }, 8800) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => { + clearInterval(poll) + clearTimeout(timer) + clearTimeout(escalation) + server.stop(true) + }) + const exitAt = Date.now(), + rows = await effects(), + begin = rows.find((e) => e.kind === "ready") + const before = begin ? await Promise.all([state(begin.pid), state(begin.grandchild)]) : [] + const cleanupSignals: unknown[] = [] + if (begin && before[0].exists && before[0].state !== "Z") { + try { + process.kill(begin.pid, "SIGTERM") + cleanupSignals.push({ pid: begin.pid, signal: "SIGTERM", at: Date.now() }) + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ESRCH") throw e + } + await Bun.sleep(250) + } + const after = begin ? await Promise.all([state(begin.pid), state(begin.grandchild)]) : [] + const logs: string[] = [] + for (const name of await fs.readdir(path.join(cwd, "data/aictrl/log")).catch(() => [])) + logs.push(await fs.readFile(path.join(cwd, "data/aictrl/log", name), "utf8")) + const attempts = (await fs.readFile(network, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const events = stdout + .split("\n") + .filter((v) => v.startsWith("{")) + .map((v) => JSON.parse(v)) + const result = { + input, + launch: { executable: process.execPath, args, env, cwd }, + code, + deadline, + groupGone: !groupExists(), + sentAt, + exitAt, + signalToExitMs: sentAt === undefined ? undefined : exitAt - sentAt, + signalReceipt, + stdout, + stderr, + logs, + requests, + attempts, + effects: rows, + beforeManualCleanup: before, + cleanupSignals, + afterManualCleanup: after, + events, + } + if (process.env.BASH_ENV_SIGNAL_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_SIGNAL_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join( + process.env.BASH_ENV_SIGNAL_EVIDENCE, + `${input.entry}-${input.signal ?? "completed"}-${input.policy}.json`, + ), + JSON.stringify(result, null, 2) + "\n", + ) + } + expect(deadline, JSON.stringify(result)).toBe(false) + expect(result.groupGone).toBe(true) + expect(attempts.some((e) => !e.allowed)).toBe(false) + if (input.control) { + expect(code, stderr + stdout).toBe(0) + expect(requests).toHaveLength(2) + expect(events.at(-1)?.type).toBe("invocation_complete") + return + } + expect(signalReceipt, JSON.stringify(result)).toBeDefined() + expect(code, stderr + stdout).toBe(input.signal === "SIGINT" ? 130 : 143) + expect(result.signalToExitMs!, JSON.stringify(result)).toBeLessThan(3000) + expect( + rows.some((e) => e.kind === "reaped"), + JSON.stringify(result), + ).toBe(true) + expect( + before.every((e) => !e.exists), + JSON.stringify(result), + ).toBe(true) + expect(cleanupSignals).toEqual([]) + expect(logs.join("\n")).not.toContain("session abort failed") + const terminal = events.filter((e) => e.type === "session_complete" || e.type === "invocation_complete") + expect(terminal.map((e) => e.type)).toEqual(["session_complete", "invocation_complete"]) + expect(events.at(-1)?.type).toBe("invocation_complete") + expect( + events + .filter((e) => e.type === "session_error") + .some((e) => e.reason === (input.signal === "SIGINT" ? "interrupted" : "terminated")), + ).toBe(true) +} +for (const entry of ["index", "headless"] as const) { + for (const signal of ["SIGINT", "SIGTERM"] as const) + for (const policy of ["inherit", "empty", "selected"] as const) + test(`${entry} ${signal} ${policy} local running Bash reaps before CLI completion`, async () => + run({ entry, signal, policy })) + test(`${entry} completed local control`, async () => run({ entry, policy: "inherit", control: true })) +} diff --git a/packages/cli/test/cli/bash-env-provider-signal.test.ts b/packages/cli/test/cli/bash-env-provider-signal.test.ts new file mode 100644 index 00000000..ba261268 --- /dev/null +++ b/packages/cli/test/cli/bash-env-provider-signal.test.ts @@ -0,0 +1,237 @@ +import { expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" + +async function provider() { + const requests: { at: number; auth: string | null; body: unknown }[] = [], + observations: { kind: string; at: number }[] = [] + const encoder = new TextEncoder() + let heartbeat: ReturnType | undefined + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + requests.push({ at: Date.now(), auth: request.headers.get("authorization"), body: await request.json() }) + request.signal.addEventListener( + "abort", + () => { + observations.push({ kind: "request-abort", at: Date.now() }) + if (heartbeat) clearInterval(heartbeat) + }, + { once: true }, + ) + return new Response( + new ReadableStream({ + start(controller) { + observations.push({ kind: "stream-start", at: Date.now() }) + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "Owned in flight" }, finish_reason: null }] })}\n\n`, + ), + ) + heartbeat = setInterval(() => { + controller.enqueue(encoder.encode(": owned keepalive\n\n")) + }, 100) + }, + cancel() { + observations.push({ kind: "stream-cancel", at: Date.now() }) + if (heartbeat) clearInterval(heartbeat) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + return { + server, + requests, + observations, + stop() { + if (heartbeat) clearInterval(heartbeat) + server.stop(true) + }, + } +} +async function waitClosed(observations: { kind: string; at: number }[]) { + const until = Date.now() + 1000 + while (Date.now() < until) { + if (observations.some((x) => x.kind === "request-abort" || x.kind === "stream-cancel")) return true + await Bun.sleep(10) + } + return false +} +async function record(name: string, value: unknown) { + if (process.env.BASH_ENV_PROVIDER_SIGNAL_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_PROVIDER_SIGNAL_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join(process.env.BASH_ENV_PROVIDER_SIGNAL_EVIDENCE, name + ".json"), + JSON.stringify(value, null, 2) + "\n", + ) + } +} +test("owned streaming provider observer detects an actual client abort", async () => { + const p = await provider(), + controller = new AbortController() + let closed = false + try { + const response = await fetch(p.server.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal: controller.signal, + }) + await response.body!.getReader().read() + controller.abort() + closed = await waitClosed(p.observations) + await record("observer-positive", { + requests: p.requests, + observations: p.observations, + closedBeforeServerStop: closed, + }) + expect(closed).toBe(true) + } finally { + p.stop() + } +}) +for (const entry of ["index", "headless"] as const) + for (const signal of ["SIGINT", "SIGTERM"] as const) + test(`${entry} ${signal} cancels an actual in-flight provider request before grace`, async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-provider-signal-")) + const network = path.join(cwd, "network.jsonl") + await fs.writeFile(network, "") + const p = await provider() + await fs.writeFile( + path.join(cwd, "aictrl.json"), + JSON.stringify({ + enabled_providers: ["fixture"], + provider: { + fixture: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "{env:AICTRL_PROVIDER_SIGNAL_KEY}", baseURL: p.server.url.origin + "/v1" }, + models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } }, + }, + }, + agent: { title: { disable: true } }, + }), + ) + const env = { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(":"), + HOME: path.join(cwd, "home"), + AICTRL_TEST_HOME: path.join(cwd, "home"), + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(cwd, "managed"), + XDG_DATA_HOME: path.join(cwd, "data"), + XDG_CONFIG_HOME: path.join(cwd, "config"), + XDG_CACHE_HOME: path.join(cwd, "cache"), + XDG_STATE_HOME: path.join(cwd, "state"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(cwd, "bun-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + SHELL: "/bin/bash", + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + AICTRL_PROVIDER_SIGNAL_KEY: "fake-provider-signal", + BASH_ENV_NETWORK_LEDGER: network, + BASH_ENV_PROVIDER_ORIGIN: p.server.url.origin, + TZ: "UTC", + } + const args = [ + "run", + "--no-install", + "--no-env-file", + "--preload", + path.join(import.meta.dir, "fixture/bash-env-forwarding-guard.ts"), + "--conditions=browser", + path.resolve(import.meta.dir, `../../src/${entry}.ts`), + "run", + "--bash-env-allowlist", + "[]", + "--format", + "json", + "--model", + "fixture/fixture", + "Respond using the owned provider.", + ] + const child = spawn(process.execPath, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "", + stderr = "", + sentAt: number | undefined, + deadline = false + child.stdout!.on("data", (v) => (stdout += v)) + child.stderr!.on("data", (v) => (stderr += v)) + const exists = () => { + try { + process.kill(-child.pid!, 0) + return true + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ESRCH") return false + throw e + } + } + const poll = setInterval(() => { + const ready = p.observations.find((x) => x.kind === "stream-start") + if (sentAt === undefined && p.requests.length === 1 && ready && Date.now() - ready.at >= 100) { + sentAt = Date.now() + child.kill(signal) + } + }, 10) + const timer = setTimeout(() => { + deadline = true + if (exists()) process.kill(-child.pid!, "SIGTERM") + }, 8500), + kill = setTimeout(() => { + if (exists()) process.kill(-child.pid!, "SIGKILL") + }, 8800) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => { + clearInterval(poll) + clearTimeout(timer) + clearTimeout(kill) + }) + const exitAt = Date.now(), + closedBeforeServerStop = await waitClosed(p.observations) + const attempts = (await fs.readFile(network, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const events = stdout + .split("\n") + .filter((v) => v.startsWith("{")) + .map((v) => JSON.parse(v)) + const result = { + entry, + signal, + launch: { executable: process.execPath, args, env, cwd }, + code, + deadline, + groupGone: !exists(), + sentAt, + exitAt, + signalToExitMs: sentAt === undefined ? undefined : exitAt - sentAt, + requests: p.requests, + observations: [...p.observations], + closedBeforeServerStop, + stdout, + stderr, + attempts, + events, + } + await record(entry + "-" + signal, result) + p.stop() + expect(deadline, JSON.stringify(result)).toBe(false) + expect(result.groupGone).toBe(true) + expect(p.requests).toHaveLength(1) + expect(p.requests[0].auth).toBe("Bearer fake-provider-signal") + expect(sentAt).toBeDefined() + expect(code, stderr + stdout).toBe(signal === "SIGINT" ? 130 : 143) + expect(result.signalToExitMs!, JSON.stringify(result)).toBeLessThan(3000) + expect(closedBeforeServerStop, JSON.stringify(result)).toBe(true) + expect(attempts.some((x) => !x.allowed)).toBe(false) + expect( + events.filter((x) => x.type === "session_complete" || x.type === "invocation_complete").map((x) => x.type), + ).toEqual(["session_complete", "invocation_complete"]) + expect(events.at(-1)?.type).toBe("invocation_complete") + }) diff --git a/packages/cli/test/cli/bash-env-startup.test.ts b/packages/cli/test/cli/bash-env-startup.test.ts new file mode 100644 index 00000000..b730552b --- /dev/null +++ b/packages/cli/test/cli/bash-env-startup.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from "bun:test" +import { spawn } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const guard = path.join(import.meta.dir, "fixture/bash-env-startup.ts") +const canary = "FAKE_POLICY_CANARY_b19f" +type Input = { + name: string + entry: "index" | "headless" + args: string[] + dotenv?: string + legacy?: string + locale?: string + mode?: string + deadlineMs?: number +} +async function probe(input: Input) { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-bash-env-startup-")) + await fs.writeFile(path.join(cwd, ".env"), input.dotenv ?? "") + await fs.writeFile(path.join(cwd, "aictrl.json"), JSON.stringify({ plugin: ["./owned-plugin.ts"] })) + await fs.writeFile( + path.join(cwd, "owned-plugin.ts"), + 'import {appendFileSync} from "node:fs"; appendFileSync(process.env.BASH_ENV_TEST_LEDGER, JSON.stringify({kind:"owned-plugin-load"})+"\\n"); throw new Error("Owned plugin must not load")\n', + ) + const ledger = path.join(cwd, "effects.jsonl") + await fs.writeFile(ledger, "") + const env: Record = { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter), + HOME: path.join(cwd, "home"), + AICTRL_TEST_HOME: path.join(cwd, "home"), + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(cwd, "managed"), + XDG_DATA_HOME: path.join(cwd, "data"), + XDG_CONFIG_HOME: path.join(cwd, "config"), + XDG_CACHE_HOME: path.join(cwd, "cache"), + XDG_STATE_HOME: path.join(cwd, "state"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(cwd, "bun-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + TZ: "UTC", + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + BASH_ENV_TEST_LEDGER: ledger, + BASH_ENV_TEST_MODE: input.mode ?? "capture", + } + if (input.locale !== undefined) env.LC_ALL = input.locale + if (input.legacy !== undefined) env.AICTRL_BASH_ENV_ALLOWLIST = input.legacy + const command = [ + "run", + "--no-install", + "--preload", + guard, + "--conditions=browser", + path.resolve(import.meta.dir, `../../src/${input.entry}.ts`), + "run", + ...input.args, + ] + const child = spawn(process.execPath, command, { cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"] }) + let stdout = "", + stderr = "", + deadline = false + child.stdout!.on("data", (value) => (stdout += value.toString())) + child.stderr!.on("data", (value) => (stderr += value.toString())) + child.stdin!.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE") stderr += String(error) + }) + // Keep selected stdin deliberately open: invalid policy must not await its EOF. + const exists = () => { + try { + process.kill(-child.pid!, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false + throw error + } + } + const started = performance.now() + const timer = setTimeout(() => { + deadline = true + if (exists()) process.kill(-child.pid!, "SIGTERM") + }, input.deadlineMs ?? 12000) + const escalation = setTimeout( + () => { + if (exists()) process.kill(-child.pid!, "SIGKILL") + }, + (input.deadlineMs ?? 12000) + 500, + ) + let code: number | null = null + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }) + } finally { + clearTimeout(timer) + clearTimeout(escalation) + child.stdin!.destroy() + if (exists()) { + process.kill(-child.pid!, "SIGKILL") + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + const effects = (await fs.readFile(ledger, "utf8")) + .split("\n") + .filter(Boolean) + .map((value) => JSON.parse(value)) + const logs: string[] = [] + async function readLogs(dir: string) { + for (const item of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) { + const file = path.join(dir, item.name) + if (item.isDirectory()) await readLogs(file) + else if (item.name.endsWith(".log")) logs.push(await fs.readFile(file, "utf8")) + } + } + await readLogs(path.join(cwd, "data")) + const result = { + input, + command, + cwd, + code, + deadline, + durationMs: performance.now() - started, + groupGone: !exists(), + stdinEOF: false, + stdout, + stderr, + logs, + effects, + } + if (process.env.BASH_ENV_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join(process.env.BASH_ENV_EVIDENCE, `${input.entry}-${input.name}.json`), + JSON.stringify(result, null, 2) + "\n", + ) + } + expect(result.groupGone, JSON.stringify(result)).toBe(true) + if (input.mode !== "deadline") expect(deadline, JSON.stringify(result)).toBe(false) + return result +} +function handler(result: Awaited>) { + expect(result.code, result.stderr + result.stdout).toBe(0) + expect(result.effects.filter((row) => row.kind === "handler")).toHaveLength(1) + expect( + result.effects.filter((row) => + [ + "config", + "config-read", + "project-config-access", + "owned-plugin-load", + "plugin", + "provider", + "stdin", + "instance", + "network", + "child", + ].includes(row.kind), + ), + ).toEqual([]) + return result.effects.find((row) => row.kind === "handler")! +} +function rejected(result: Awaited>) { + expect(result.code, result.stderr + result.stdout).toBe(2) + expect(result.effects.filter((row) => row.kind !== "preload")).toEqual([]) + const output = [result.stdout, result.stderr, ...result.logs].join("\n") + expect(output).toContain("BASH_ENV_CONFIGURATION") + expect(output).not.toContain(canary) + expect(output).not.toContain("SyntaxError") +} +for (const entry of ["index", "headless"] as const) + describe(entry, () => { + test("unset policy ignores a project dotenv policy-looking key", async () => { + const r = await probe({ + name: "unset-dotenv", + entry, + args: ["task"], + dotenv: 'AICTRL_BASH_ENV_ALLOWLIST=["PROJECT_WIDER"]\n', + }) + expect(r.effects[0].legacy).toBe('["PROJECT_WIDER"]') + expect(handler(r).policy).toEqual({ mode: "inherit" }) + }) + test("legacy caller policy-looking key has no authority", async () => { + const r = await probe({ name: "unset-legacy", entry, args: ["task"], legacy: "[]" }) + expect(handler(r).policy).toEqual({ mode: "inherit" }) + }) + for (const value of ["[]", '["PATH","HOME"]']) + test(`explicit ${value} defeats wider dotenv`, async () => { + const r = await probe({ + name: value === "[]" ? "empty-list" : "selected-list", + entry, + args: ["--bash-env-allowlist", value, "task"], + dotenv: 'AICTRL_BASH_ENV_ALLOWLIST=["PROJECT_WIDER"]\n', + }) + const h = handler(r) + expect(h.policy).toEqual({ mode: "allowlist", names: JSON.parse(value) }) + expect(h.frozen).toBe(true) + expect(h.namesFrozen).toBe(true) + }) + test("equals form owns exactly one JSON value", async () => { + const r = await probe({ name: "equals", entry, args: ['--bash-env-allowlist=["PATH"]', "task"] }) + expect(handler(r).policy).toEqual({ mode: "allowlist", names: ["PATH"] }) + }) + for (const [name, args] of [ + ["explicit-empty", ["--bash-env-allowlist", ""]], + ["malformed", ["--bash-env-allowlist", `{${canary}`]], + ["wrong-object", ["--bash-env-allowlist", JSON.stringify({ [canary]: true })]], + ["wrong-null", ["--bash-env-allowlist", "null"]], + ["wrong-number", ["--bash-env-allowlist", "21"]], + ["wrong-element", ["--bash-env-allowlist", `["${canary}",3]`]], + ["equals-name", ["--bash-env-allowlist", `["BAD=${canary}"]`]], + ["nul-name", ["--bash-env-allowlist", `["BAD\\u0000${canary}"]`]], + ["wildcard-name", ["--bash-env-allowlist", `["*${canary}"]`]], + ["repeated", ["--bash-env-allowlist", `["${canary}"]`, "--bash-env-allowlist", "[]"]], + ["hybrid-repeated", ["--bash-env-allowlist", `["${canary}"]`, "--bashEnvAllowlist", "[]"]], + ["missing", ["--bash-env-allowlist"]], + ["missing-before-option", ["--bash-env-allowlist", "--format", "json"]], + ] as [string, string[]][]) + test(name + " fails early with redacted classification", async () => + rejected(await probe({ name, entry, args: [...args, ...(name === "missing" ? [] : ["-"])] })), + ) + for (const [name, attach] of [ + ["url", ["--attach", "http://127.0.0.1:9/" + canary]], + ["empty", ["--attach="]], + ] as [string, string[]][]) { + for (const policy of ["[]", '["PATH"]']) + test(`unsupported attach ${name} with ${policy}`, async () => + rejected( + await probe({ + name: `attach-${name}-${policy === "[]" ? "empty" : "selected"}`, + entry, + args: ["--bash-env-allowlist", policy, ...attach, "-"], + }), + )) + } + test("localized missing policy value retains safe classification", async () => { + rejected(await probe({ name: "french-missing", entry, locale: "fr_FR.UTF-8", args: ["--bash-env-allowlist"] })) + }) + test("other yargs errors on explicit policy redact their input", async () => { + rejected( + await probe({ + name: "other-parser-canary", + entry, + args: ["--bash-env-allowlist", "[]", "--format", canary, "task"], + }), + ) + }) + test("earlier yargs errors also redact later explicit policy", async () => { + rejected( + await probe({ + name: "earlier-parser-canary", + entry, + args: ["--format", canary, "--bash-env-allowlist", "[]", "task"], + }), + ) + }) + test("required scalar owns policy-looking text", async () => { + const r = await probe({ name: "scalar-ownership", entry, args: ["--title=--bash-env-allowlist", "task"] }) + const h = handler(r) + expect(h.policy).toEqual({ mode: "inherit" }) + expect(h.parsed.title).toBe("--bash-env-allowlist") + }) + test("post-marker policy-looking and numeric data stay data", async () => { + const r = await probe({ + name: "marker-data", + entry, + args: ["--", "--bash-env-allowlist", "[]", "-21", "007", "-"], + }) + const h = handler(r) + expect(h.policy).toEqual({ mode: "inherit" }) + expect(h.parsed["--"]).toEqual(["--bash-env-allowlist", "[]", "-21", "007", "-"]) + }) + test("policy log summary excludes raw policy names", async () => { + const r = await probe({ + name: "log-redaction", + entry, + args: ["--bash-env-allowlist", `["${canary}"]`, "--print-logs", "task"], + }) + handler(r) + expect([r.stdout, r.stderr, ...r.logs].join("\n")).not.toContain(canary) + expect(r.stderr).toContain("aictrl") + expect(handler(r).argv).toEqual(["run", ...r.input.args]) + }) + test("file log summary excludes raw policy names", async () => { + const r = await probe({ + name: "file-log-redaction", + entry, + args: ["--bash-env-allowlist", `["${canary}"]`, "task"], + }) + expect(handler(r).argv).toEqual(["run", ...r.input.args]) + expect(r.logs.length).toBeGreaterThan(0) + expect([r.stdout, r.stderr, ...r.logs].join("\n")).not.toContain(canary) + expect(r.logs.join("\n")).toContain("bashEnvPolicy") + }) + test("real local handler reaches the instrumented plugin boundary", async () => { + const r = await probe({ + name: "real-plugin-control", + entry, + mode: "real", + args: ["--bash-env-allowlist", "[]", "task"], + }) + expect(r.effects.filter((row) => row.kind === "handler")).toHaveLength(1) + expect(r.effects.filter((row) => row.kind === "instance")).toHaveLength(1) + expect(r.effects.filter((row) => row.kind === "plugin")).toHaveLength(1) + expect(r.effects.filter((row) => ["provider", "network", "child"].includes(row.kind))).toEqual([]) + }) + test("unset policy preserves attach path into config preflight", async () => { + const r = await probe({ + name: "real-config-control", + entry, + mode: "real", + args: ["--attach", "http://127.0.0.1:9/owned", "task"], + }) + expect(r.effects.filter((row) => row.kind === "handler")).toHaveLength(1) + expect(r.effects.filter((row) => row.kind === "config-read")).toHaveLength(1) + expect(r.effects.filter((row) => ["provider", "network", "child", "plugin"].includes(row.kind))).toEqual([]) + }) + test("real attach preflight reads only the owned project config before guarded receiver dispatch", async () => { + const r = await probe({ + name: "real-config-access-control", + entry, + mode: "real-config", + args: ["--attach", "http://127.0.0.1:9/owned", "task"], + }) + expect(r.effects.filter((row) => row.kind === "project-config-access").length).toBeGreaterThan(0) + expect(r.effects.filter((row) => row.kind === "config-read")).toHaveLength(1) + expect( + r.effects.filter((row) => ["provider", "child", "owned-plugin-load", "plugin"].includes(row.kind)), + ).toEqual([]) + }) + test("actual valid handler observes selected stdin before its EOF", async () => { + const r = await probe({ + name: "real-stdin-control", + entry, + mode: "real", + args: ["--bash-env-allowlist", "[]", "-"], + }) + expect(r.effects.filter((row) => row.kind === "handler")).toHaveLength(1) + expect(r.effects.filter((row) => row.kind === "stdin")).toHaveLength(1) + expect( + r.effects.filter((row) => ["config", "plugin", "provider", "network", "child"].includes(row.kind)), + ).toEqual([]) + }) + }) +test("boundary instrumentation has positive controls", async () => { + const r = await probe({ name: "boundary-controls", entry: "index", mode: "controls", args: ["task"] }) + expect(r.effects.filter((row) => row.kind === "control").map((row) => row.boundary)).toEqual([ + "config", + "plugin", + "provider", + "stdin", + "network", + ]) +}) +test("owned deadline terminates and reaps a real grandchild", async () => { + const r = await probe({ name: "deadline-control", entry: "index", mode: "deadline", deadlineMs: 500, args: ["task"] }) + expect(r.deadline).toBe(true) + expect(r.effects.filter((row) => row.kind === "grandchild")).toHaveLength(1) + expect(r.effects.filter((row) => row.kind === "grandchild-reaped")).toHaveLength(1) +}) diff --git a/packages/cli/test/cli/exception-handler.test.ts b/packages/cli/test/cli/exception-handler.test.ts index 48d2a8df..00853725 100644 --- a/packages/cli/test/cli/exception-handler.test.ts +++ b/packages/cli/test/cli/exception-handler.test.ts @@ -1,9 +1,99 @@ import { describe, expect, test } from "bun:test" import path from "path" import { fileURLToPath } from "url" +import { EOL } from "os" +import ts from "typescript" +import { BashEnvPolicy } from "../../src/util/bash-env-policy" const __dirname = path.dirname(fileURLToPath(import.meta.url)) +// Execute the source-extracted callbacks with controlled effects. This checks the +// actual handler bodies, not whole-CLI startup or global event delivery. +async function handlers(file: string) { + const source = ts.createSourceFile( + file, + await Bun.file(path.join(__dirname, "../../src", file)).text(), + ts.ScriptTarget.Latest, + true, + ) + for (const event of ["uncaughtException", "unhandledRejection"] as const) { + const registrations = source.statements.filter((statement): statement is ts.ExpressionStatement => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return false + const call = statement.expression + return ( + ts.isPropertyAccessExpression(call.expression) && + ts.isIdentifier(call.expression.expression) && + call.expression.expression.text === "process" && + call.expression.name.text === "on" && + !!call.arguments[0] && + ts.isStringLiteral(call.arguments[0]) && + call.arguments[0].text === event + ) + }) + expect(registrations).toHaveLength(1) + const call = registrations[0]?.expression + if (!call || !ts.isCallExpression(call) || call.arguments.length !== 2) { + throw new Error(`Expected one module-top process.on callback for ${file}: ${event}`) + } + const callback = call.arguments[1] + if (!callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))) { + throw new Error(`Expected an inline callback for ${file}: ${event}`) + } + const compiled = new Bun.Transpiler({ loader: "ts" }).transformSync(`const handler = ${callback.getText(source)}`) + const marker = "owned-sensitive-policy-marker" + let policy: unknown + try { + BashEnvPolicy.parse(`["${marker}`) + } catch (error) { + policy = error + } + expect(policy).toBeInstanceOf(BashEnvPolicy.ConfigError) + const ordinary = new Error("owned ordinary exception") + for (const error of [ordinary, policy]) { + const logs: { event: string; data: Record }[] = [] + const stderr: string[] = [] + const exits: number[] = [] + const sentinel = new Error("controlled process.exit") + const handler = new Function("BashEnvPolicy", "Log", "process", "EOL", compiled + "\nreturn handler")( + BashEnvPolicy, + { Default: { error: (event: string, data: Record) => logs.push({ event, data }) } }, + { + stderr: { write: (value: string) => stderr.push(value) }, + exit: (code: number): never => { + exits.push(code) + throw sentinel + }, + }, + EOL, + ) as (error: unknown) => void + let thrown: unknown + try { + handler(error) + } catch (error) { + thrown = error + } + expect(thrown).toBe(sentinel) + if (error === ordinary) { + expect(exits).toEqual([1]) + expect(stderr).toEqual([]) + expect(logs).toEqual([ + { + event: event === "uncaughtException" ? "exception" : "rejection", + data: { e: ordinary.message, stack: ordinary.stack }, + }, + ]) + } else { + expect(exits).toEqual([2]) + expect(stderr).toEqual([ + "BASH_ENV_CONFIGURATION: --bash-env-allowlist must be a JSON array of variable names." + EOL, + ]) + expect(logs).toEqual([]) + expect(JSON.stringify({ stderr, logs })).not.toContain(marker) + } + } + } +} + describe("exception handler exit behavior", () => { test("uncaughtException handler exits with code 1", async () => { // Spawn a minimal script that: @@ -58,47 +148,12 @@ describe("exception handler exit behavior", () => { expect(stderr).toContain("test unhandled rejection") }, 10_000) - test("index.ts exception handlers include process.exit(1)", async () => { - // Static analysis: ensure handlers include process.exit(1) - const indexSource = await Bun.file(path.join(__dirname, "../../src/index.ts")).text() - - // Verify both handlers exist and include process.exit(1) - // Match the pattern: process.on("uncaughtException", ...) block containing process.exit(1) - expect(indexSource).toContain('process.on("uncaughtException"') - expect(indexSource).toContain('process.on("unhandledRejection"') - - // Verify process.exit(1) appears after both handlers (crude but effective) - const uncaughtIdx = indexSource.indexOf('process.on("uncaughtException"') - const rejectionIdx = indexSource.indexOf('process.on("unhandledRejection"') - - // Find the next process.exit(1) after each handler registration - const exitAfterUncaught = indexSource.indexOf("process.exit(1)", uncaughtIdx) - const exitAfterRejection = indexSource.indexOf("process.exit(1)", rejectionIdx) - - // Both should find process.exit(1) within the handler (before the next major block) - expect(exitAfterUncaught).toBeGreaterThan(uncaughtIdx) - expect(exitAfterRejection).toBeGreaterThan(rejectionIdx) - // Handlers are close together, so exit should be within ~200 chars - expect(exitAfterUncaught - uncaughtIdx).toBeLessThan(200) - expect(exitAfterRejection - rejectionIdx).toBeLessThan(200) + test("index.ts actual callbacks classify ordinary and policy errors", async () => { + await handlers("index.ts") }) - test("headless.ts exception handlers include process.exit(1)", async () => { - const headlessSource = await Bun.file(path.join(__dirname, "../../src/headless.ts")).text() - - expect(headlessSource).toContain('process.on("uncaughtException"') - expect(headlessSource).toContain('process.on("unhandledRejection"') - - const uncaughtIdx = headlessSource.indexOf('process.on("uncaughtException"') - const rejectionIdx = headlessSource.indexOf('process.on("unhandledRejection"') - - const exitAfterUncaught = headlessSource.indexOf("process.exit(1)", uncaughtIdx) - const exitAfterRejection = headlessSource.indexOf("process.exit(1)", rejectionIdx) - - expect(exitAfterUncaught).toBeGreaterThan(uncaughtIdx) - expect(exitAfterRejection).toBeGreaterThan(rejectionIdx) - expect(exitAfterUncaught - uncaughtIdx).toBeLessThan(200) - expect(exitAfterRejection - rejectionIdx).toBeLessThan(200) + test("headless.ts actual callbacks classify ordinary and policy errors", async () => { + await handlers("headless.ts") }) test("exception handlers log stack traces", async () => { diff --git a/packages/cli/test/cli/fixture/bash-env-forwarding-guard.ts b/packages/cli/test/cli/fixture/bash-env-forwarding-guard.ts new file mode 100644 index 00000000..975c0b4b --- /dev/null +++ b/packages/cli/test/cli/fixture/bash-env-forwarding-guard.ts @@ -0,0 +1,41 @@ +import { appendFileSync } from "node:fs" +const record = (value: unknown) => appendFileSync(process.env.BASH_ENV_NETWORK_LEDGER!, JSON.stringify(value) + "\n") +const fetch = globalThis.fetch +const spawn = Bun.spawn.bind(Bun) +Bun.spawn = ((...args: Parameters) => { + const first: unknown = args[0] + const cmd = Array.isArray(first) + ? first + : first && typeof first === "object" && "cmd" in first + ? first.cmd + : undefined + if (Array.isArray(cmd) && /(?:^|\/)bun$/.test(String(cmd[0])) && ["install", "add"].includes(String(cmd[1]))) { + record({ kind: "install", allowed: false }) + throw new Error("Owned forwarding fixture forbids install") + } + return spawn(...args) +}) as typeof Bun.spawn +globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + const allowed = + process.env.BASH_ENV_PROVIDER_ORIGIN !== undefined && url.origin === process.env.BASH_ENV_PROVIDER_ORIGIN + record({ + kind: "fetch", + origin: url.origin, + path: url.pathname, + allowed, + parentKey: process.env.CUSTOM_GATEWAY_KEY, + parentGh: process.env.GH_TOKEN, + parentProviderKey: process.env.AICTRL_TEST_PROVIDER_KEY, + }) + if (!allowed) throw new Error("Owned forwarding fixture denied network") + return fetch(input, init) + }, + { + preconnect: () => { + record({ kind: "preconnect", allowed: false }) + throw new Error("Owned forwarding fixture forbids preconnect") + }, + }, +) as typeof fetch diff --git a/packages/cli/test/cli/fixture/bash-env-startup.ts b/packages/cli/test/cli/fixture/bash-env-startup.ts new file mode 100644 index 00000000..ea24f080 --- /dev/null +++ b/packages/cli/test/cli/fixture/bash-env-startup.ts @@ -0,0 +1,117 @@ +import { appendFileSync } from "node:fs" +import path from "node:path" + +const record = (kind: string, detail: Record = {}) => + appendFileSync(process.env.BASH_ENV_TEST_LEDGER!, JSON.stringify({ kind, ...detail }) + "\n") +record("preload", { legacy: process.env.AICTRL_BASH_ENV_ALLOWLIST ?? null }) +const original = globalThis.fetch +// No provider or receiver request is permitted in this parser/startup unit. +globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + record("network", { origin: url.origin, path: url.pathname }) + throw new Error("Owned startup fixture forbids network") + }, + { + preconnect: () => { + record("network-preconnect") + throw new Error("Owned startup fixture forbids preconnect") + }, + }, +) as typeof original +const spawn = Bun.spawn.bind(Bun) +Bun.spawn = ((...args: Parameters) => { + record("child") + throw new Error("Owned startup fixture forbids child launch") +}) as unknown as typeof Bun.spawn + +if (process.env.BASH_ENV_TEST_MODE === "deadline") { + const child = spawn([process.execPath, "--no-env-file", "-e", "setInterval(() => {}, 1000)"], { + env: { PATH: "/usr/bin:/bin" }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }) + record("grandchild", { pid: child.pid }) + process.on("SIGTERM", async () => { + child.kill("SIGTERM") + await child.exited + record("grandchild-reaped") + process.exit(143) + }) + await new Promise(() => {}) +} + +const { Filesystem } = await import("../../../src/util/filesystem") +const readText = Filesystem.readText +Filesystem.readText = async (target: string) => { + if (path.resolve(target) === path.join(process.cwd(), "aictrl.json")) record("project-config-access") + return readText(target) +} +const { Config } = await import("../../../src/config/config") +const { Plugin } = await import("../../../src/plugin") +const { Provider } = await import("../../../src/provider/provider") +const { Instance } = await import("../../../src/project/instance") +const { RunCommand } = await import("../../../src/cli/cmd/run") +const { BashEnvPolicy } = await import("../../../src/util/bash-env-policy") + +Config.get = async () => { + record("config") + throw new Error("Owned config boundary reached") +} +const read = Config.readToolSelection +Config.readToolSelection = async (...args: Parameters) => { + record("config-read") + if (process.env.BASH_ENV_TEST_MODE === "real-config") return read(...args) + throw new Error("Owned config read boundary reached") +} +Plugin.init = async () => { + record("plugin") + throw new Error("Owned plugin boundary reached") +} +const model = Provider.getModel +Provider.getModel = async (...args: Parameters) => { + record("provider") + throw new Error("Owned provider boundary reached") +} +const provide = Instance.provide +Instance.provide = (async (...args: Parameters) => { + record("instance") + return provide(...args) +}) as typeof provide +Bun.stdin.text = async () => { + record("stdin") + throw new Error("Owned stdin boundary reached") +} +const handler = RunCommand.handler +RunCommand.handler = async (args) => { + record("handler", { + policy: BashEnvPolicy.current(), + parsed: args, + argv: process.argv.slice(2), + frozen: Object.isFrozen(BashEnvPolicy.current()), + namesFrozen: + BashEnvPolicy.current().mode === "allowlist" + ? Object.isFrozen((BashEnvPolicy.current() as { names: readonly string[] }).names) + : null, + }) + // The normal CLI exits immediately after its handler; let the real asynchronous log stream flush. + await new Promise((resolve) => setTimeout(resolve, 50)) + if (["real", "real-config"].includes(process.env.BASH_ENV_TEST_MODE!)) return handler(args) +} +// Instrumentation positive controls use the actual installed boundary functions. +if (process.env.BASH_ENV_TEST_MODE === "controls") { + for (const [kind, fn] of [ + ["config", () => Config.get()], + ["plugin", () => Plugin.init()], + ["provider", () => Provider.getModel("fixture", "fixture")], + ["stdin", () => Bun.stdin.text()], + ["network", () => fetch("http://127.0.0.1:9/owned")], + ] as const) { + try { + await fn() + } catch { + record("control", { boundary: kind }) + } + } +} diff --git a/packages/cli/test/tool/bash-env.test.ts b/packages/cli/test/tool/bash-env.test.ts new file mode 100644 index 00000000..dce0ea51 --- /dev/null +++ b/packages/cli/test/tool/bash-env.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { spawn } from "node:child_process" + +import { BashEnvPolicy } from "../../src/util/bash-env-policy" + +const canary = "FAKE_VALUE_CANARY_f810" +type Input = { + name: string + policy: readonly string[] | null + hook?: string + deny?: boolean + cancel?: boolean + timeout?: number + command?: string + residual?: boolean + trace?: boolean + parentPrototype?: boolean + preAbort?: boolean + repeat?: number + shell?: string + guardNegative?: "fetch" | "install" +} +async function run(input: Input) { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-bash-env-tool-")) + const bin = path.join(cwd, "bin") + await fs.mkdir(bin) + const ledger = path.join(cwd, "ledger.jsonl") + await fs.writeFile(ledger, "") + const network = path.join(cwd, "network.jsonl") + await fs.writeFile(network, "") + const helper = path.join(cwd, "child.ts") + await fs.writeFile( + helper, + `import{appendFileSync}from"node:fs";appendFileSync(${JSON.stringify(ledger)},JSON.stringify({kind:"final"})+"\\n");console.log(JSON.stringify({env:process.env,cwd:process.cwd()}))`, + ) + const realpath = path.join(cwd, "realpath.ts") + await fs.writeFile( + realpath, + `import{appendFileSync}from"node:fs";appendFileSync(${JSON.stringify(ledger)},JSON.stringify({kind:"preflight",argv:process.argv.slice(2),env:process.env,cwd:process.cwd(),pid:process.pid})+"\\n");console.log(${JSON.stringify(path.join(cwd, "target"))})`, + ) + await fs.writeFile( + path.join(bin, "realpath"), + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(realpath)} "$@"\n`, + { mode: 0o755 }, + ) + await fs.writeFile(path.join(cwd, "target"), "owned") + await fs.writeFile(path.join(cwd, "path with spaces;literal"), "owned") + const hook = path.join(cwd, "plugin.ts") + await fs.writeFile( + hook, + `import{appendFileSync}from"node:fs";export default async()=>({"shell.env":async(_input,output)=>{appendFileSync(${JSON.stringify(ledger)},JSON.stringify({kind:"hook"})+"\\n");${input.hook ?? 'output.env={ALLOWED:"hook-override",HOOK_SECRET:"fake-hook-secret",HOOK_EMPTY:""}'}}})`, + ) + await fs.writeFile(path.join(cwd, "aictrl.json"), JSON.stringify({ plugin: [hook] })) + const command = + input.command ?? `cat target >/dev/null; ${JSON.stringify(process.execPath)} ${JSON.stringify(helper)}` + const data = path.join(cwd, "input.json") + await fs.writeFile(data, JSON.stringify({ ...input, command })) + const env = Object.assign(Object.create(null), { + PATH: [bin, path.dirname(process.execPath), "/usr/bin", "/bin"].join(":"), + HOME: path.join(cwd, "home"), + AICTRL_TEST_HOME: path.join(cwd, "home"), + SHELL: "/bin/bash", + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(cwd, "managed"), + XDG_DATA_HOME: path.join(cwd, "data"), + XDG_CONFIG_HOME: path.join(cwd, "config"), + XDG_CACHE_HOME: path.join(cwd, "cache"), + XDG_STATE_HOME: path.join(cwd, "state"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: path.join(cwd, "bun-cache"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + TZ: "UTC", + BASH_ENV_INPUT: data, + BASH_ENV_LEDGER: ledger, + BASH_ENV_NETWORK_LEDGER: network, + GH_TOKEN: "fake-gh", + CUSTOM_GATEWAY_KEY: "fake-model", + ALLOWED: "parent-allowed", + EMPTY: "", + Path: "case-value", + constructor: "fake-constructor", + __proto__: "fake-prototype", + "ProgramFiles(x86)": "fake-program", + }) + Object.defineProperty(env, "__proto__", { + value: "fake-prototype", + enumerable: true, + writable: true, + configurable: true, + }) + const args = [ + "run", + "--no-install", + "--no-env-file", + "--preload", + path.resolve(import.meta.dir, "../cli/fixture/bash-env-forwarding-guard.ts"), + "--conditions=browser", + path.join(import.meta.dir, "fixture/bash-env-case.ts"), + ] + const trace = path.join(cwd, "execve.log") + if (input.shell) env.SHELL = input.shell + const executable = input.trace ? "/usr/bin/strace" : input.parentPrototype ? "/usr/bin/env" : process.execPath + const actualArgs = input.parentPrototype + ? ["__proto__=fake-parent-prototype", process.execPath, ...args] + : input.trace + ? ["-f", "-v", "-s", "8192", "-e", "trace=execve", "-o", trace, process.execPath, ...args] + : args + const child = spawn(executable, actualArgs, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "", + stderr = "", + deadline = false + child.stdout!.on("data", (v) => (stdout += v)) + child.stderr!.on("data", (v) => (stderr += v)) + const exists = () => { + try { + process.kill(-child.pid!, 0) + return true + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ESRCH") return false + throw e + } + } + const timer = setTimeout(() => { + deadline = true + if (exists()) process.kill(-child.pid!, "SIGTERM") + }, 15000) + const escalation = setTimeout(() => { + if (exists()) process.kill(-child.pid!, "SIGKILL") + }, 15200) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => { + clearTimeout(timer) + clearTimeout(escalation) + }) + const effects = (await fs.readFile(ledger, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const logs = [] + for (const name of await fs.readdir(path.join(cwd, "data/aictrl/log")).catch(() => [])) + logs.push(await fs.readFile(path.join(cwd, "data/aictrl/log", name), "utf8")) + const attempts = (await fs.readFile(network, "utf8")) + .split("\n") + .filter(Boolean) + .map((v) => JSON.parse(v)) + const result = { + launch: { executable, args: actualArgs, env }, + trace: input.trace ? await fs.readFile(trace, "utf8") : undefined, + attempts, + input, + cwd, + command, + code, + deadline, + groupGone: !exists(), + stdout, + stderr, + effects, + logs, + data: stdout.trim() ? JSON.parse(stdout.trim().split("\n").at(-1)!) : null, + } + if (process.env.BASH_ENV_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join(process.env.BASH_ENV_EVIDENCE, `${input.name}.json`), + JSON.stringify(result, null, 2) + "\n", + ) + } + expect(attempts.filter((r) => !r.allowed)).toHaveLength(input.guardNegative ? 1 : 0) + expect(deadline, JSON.stringify(result)).toBe(false) + expect(code, stderr + stdout).toBe(0) + expect(result.groupGone).toBe(true) + return result +} +function child(r: Awaited>) { + expect(r.data.outcome.error, JSON.stringify(r)).toBeUndefined() + expect(r.data.outcome.result.metadata.exit).toBe(0) + return JSON.parse(r.data.outcome.result.output.trim()) +} +test("default direct host preserves parent and actual hook overrides", async () => { + const r = await run({ name: "inherit", policy: null }) + const c = child(r) + expect(c.env.GH_TOKEN).toBe("fake-gh") + expect(c.env.CUSTOM_GATEWAY_KEY).toBe("fake-model") + expect(c.env.ALLOWED).toBe("hook-override") + expect(c.env.HOOK_SECRET).toBe("fake-hook-secret") + expect(r.effects.find((e) => e.kind === "preflight").env.GH_TOKEN).toBe("fake-gh") + expect(r.data.before).toEqual(r.data.after) +}) +test("selected parent reaches preflight and final real hook override stays selected", async () => { + const r = await run({ name: "selected", policy: ["PATH", "ALLOWED", "EMPTY", "MISSING", "HOOK_EMPTY"] }) + const c = child(r) + expect(c.env.ALLOWED).toBe("hook-override") + expect(c.env.EMPTY).toBe("") + expect(c.env.HOOK_EMPTY).toBe("") + expect(c.env).not.toHaveProperty("MISSING") + for (const key of ["GH_TOKEN", "CUSTOM_GATEWAY_KEY", "HOOK_SECRET", "HOME"]) expect(c.env).not.toHaveProperty(key) + const p = r.effects.find((e) => e.kind === "preflight") + expect(p.env.ALLOWED).toBe("parent-allowed") + expect(p.env).not.toHaveProperty("GH_TOKEN") + expect(p.env).not.toHaveProperty("CUSTOM_GATEWAY_KEY") + expect(r.effects.map((e) => e.kind)).toEqual(["preflight", "permission", "hook", "final"]) + expect(r.data.before).toEqual(r.data.after) +}) +test("empty policy excludes all supplied parent and hook entries", async () => { + const r = await run({ name: "empty", policy: [], trace: true }) + const c = child(r) + for (const key of ["PATH", "HOME", "GH_TOKEN", "CUSTOM_GATEWAY_KEY", "ALLOWED", "EMPTY", "HOOK_SECRET"]) + expect(c.env).not.toHaveProperty(key) + const line = r + .trace!.split("\n") + .find((v) => v.includes("execve(" + JSON.stringify(path.join(r.cwd, "bin/realpath"))) && v.includes('"target"')) + expect(line).toBeDefined() + for (const key of ["GH_TOKEN=", "CUSTOM_GATEWAY_KEY=", "ALLOWED=", "HOME=", "PATH="]) expect(line!).not.toContain(key) +}) +test("literal preflight argument and cwd survive exact env selection", async () => { + const r = await run({ + name: "literal", + policy: ["PATH"], + command: `cat 'path with spaces;literal' >/dev/null; printf 'done'`, + }) + expect(r.data.outcome.result.output).toBe("done") + expect(r.effects.find((e) => e.kind === "preflight").argv).toEqual(["'path with spaces;literal'"]) + expect(r.effects.find((e) => e.kind === "preflight").cwd).toBe(r.cwd) +}) +for (const [name, hook] of [ + ["null-hook", "output.env=null"], + ["array-hook", "output.env=[]"], + ["number-hook", "output.env={ALLOWED:3}"], + ["nul-hook", `output.env={ALLOWED:${JSON.stringify(canary + "\0")}}`], + ["bad-key-hook", `output.env=JSON.parse(${JSON.stringify(JSON.stringify({ ["BAD=" + canary]: "fake" }))})`], +] as const) + test(name + " rejects safely after preflight before final shell", async () => { + const r = await run({ name, policy: ["PATH", "ALLOWED"], hook }) + expect(r.data.outcome.error.code).toBe("BASH_ENV_CONFIGURATION") + expect(r.effects.map((e) => e.kind)).toEqual(["preflight", "permission", "hook"]) + expect(JSON.stringify(r.data.outcome.error) + r.stderr + r.logs.join("\n")).not.toContain(canary) + }) +test("permission denial prevents hook and final shell", async () => { + const r = await run({ name: "permission-deny", policy: ["PATH"], deny: true }) + expect(r.data.outcome.error.message).toContain("prevents you from using this specific tool call") + expect(r.effects.map((e) => e.kind)).toEqual(["preflight", "permission"]) +}) + +for (const shape of ["plain", "null"] as const) + test(shape + " prototype-looking selected keys reach native preflight and final env", async () => { + const r = await run({ + name: "prototype-keys-" + shape, + policy: ["PATH", "constructor", "__proto__"], + parentPrototype: true, + command: "cat target >/dev/null; /usr/bin/env -0", + hook: + shape === "plain" + ? `output.env=JSON.parse('{"constructor":"hook-constructor","__proto__":"hook-prototype"}')` + : `output.env=Object.assign(Object.create(null),JSON.parse('{"constructor":"hook-constructor","__proto__":"hook-prototype"}'))`, + }) + expect(r.data.outcome.error).toBeUndefined() + expect(r.data.outcome.result.output.split("\0")).toContain("__proto__=hook-prototype") + expect(r.data.outcome.result.output.split("\0")).toContain("constructor=hook-constructor") + expect(r.effects.find((e) => e.kind === "preflight").env.__proto__).toBe("fake-parent-prototype") + }) + +test("unset policy keeps legacy numeric hook coercion", async () => { + const r = await run({ name: "inherit-numeric-hook", policy: null, hook: "output.env={ALLOWED:42}" }) + expect(child(r).env.ALLOWED).toBe("42") +}) +test("POSIX names remain literal case-sensitive", async () => { + const r = await run({ + name: "posix-case", + policy: ["PATH", "Path"], + hook: 'output.env={PATH:process.env.PATH,Path:"hook-case",PATH_OTHER:"excluded"}', + }) + const c = child(r) + expect(c.env.Path).toBe("hook-case") + expect(c.env.PATH).toContain(r.cwd) + expect(c.env).not.toHaveProperty("PATH_OTHER") +}) +test("selected child still reaches owned parent proc and credential file (not confinement)", async () => { + const r = await run({ name: "residual", policy: [], residual: true }) + expect(child(r)).toEqual({ childGh: null, parentGh: "GH_TOKEN=fake-gh", credential: "fake-owned-file-credential" }) +}) +for (const kind of ["cancel", "timeout"] as const) + test(kind + " reaps actual shell and grandchild", async () => { + const r = await run({ name: kind, policy: [], ...(kind === "cancel" ? { cancel: true } : { timeout: 200 }) }) + expect(r.data.outcome.error).toBeUndefined() + expect(r.data.outcome.result.output).toContain(kind === "cancel" ? "User aborted" : "exceeding timeout") + expect(r.effects.filter((e) => e.kind === "lifecycle-start")).toHaveLength(1) + expect(r.effects.filter((e) => e.kind === "lifecycle-reaped")).toHaveLength(1) + const e = r.effects.find((e) => e.kind === "lifecycle-start") + for (const pid of [e.pid, e.grandchild]) expect(() => process.kill(pid, 0)).toThrow() + }) + +function policy(names: string[]) { + return { mode: "allowlist" as const, names } +} +test("pure selector ignores inherited and missing parent entries; keeps empty values", () => { + const parent = Object.assign(Object.create({ INHERITED: "fake-inherited" }), { EMPTY: "", ABSENT: undefined }) + const out = BashEnvPolicy.select(policy(["INHERITED", "EMPTY", "ABSENT", "MISSING"]), parent) + expect(Object.getPrototypeOf(out)).toBeNull() + expect(Object.entries(out)).toEqual([["EMPTY", ""]]) +}) +test("pure selector preserves own prototype-like keys", () => { + const parent = JSON.parse('{"__proto__":"parent-proto","constructor":"parent-constructor"}') + const hook = JSON.parse('{"__proto__":"hook-proto"}') + const out = BashEnvPolicy.select(policy(["__proto__", "constructor"]), parent, hook) + expect(Object.getPrototypeOf(out)).toBeNull() + expect(Object.entries(out)).toEqual([ + ["__proto__", "hook-proto"], + ["constructor", "parent-constructor"], + ]) + expect(Object.hasOwn(out, "__proto__")).toBe(true) +}) +test("pure Windows identity merges hook override deterministically without native claim", () => { + const out = BashEnvPolicy.select( + policy(["Path", "missing"]), + { PATH: "parent-uppercase", Path: "parent-mixed" }, + { path: "hook-lower", PATH: "hook-uppercase" }, + "win32", + ) + expect(Object.entries(out)).toEqual([["PATH", "hook-uppercase"]]) +}) +for (const [name, parent, hook] of [ + ["selected-parent-nul", { ALLOWED: canary + "\0" }, {}], + ["excluded-hook-nul", {}, { EXCLUDED: canary + "\0" }], + ["excluded-hook-number", {}, { EXCLUDED: 3 }], + ["symbol-hook", {}, { [Symbol("fake")]: "fake" }], +] as const) + test(name + " pure rejection is value-free", () => { + let error: unknown + try { + BashEnvPolicy.select(policy(["ALLOWED"]), parent, hook) + } catch (e) { + error = e + } + expect(error).toBeInstanceOf(BashEnvPolicy.ConfigError) + expect(String(error) + (error as Error).stack + JSON.stringify(error)).not.toContain(canary) + }) +for (const [name, hook] of [ + ["excluded-nul-hook", `output.env={EXCLUDED:${JSON.stringify(canary + "\0")}}`], + ["excluded-number-hook", "output.env={EXCLUDED:3}"], + ["non-record-hook", "output.env=new Date()"], +] as const) + test(name + " fails after real preflight without widening", async () => { + const r = await run({ name, policy: ["PATH"], hook }) + expect(r.data.outcome.error.code).toBe("BASH_ENV_CONFIGURATION") + expect(r.effects.map((e) => e.kind)).toEqual(["preflight", "permission", "hook"]) + expect(JSON.stringify(r.data.outcome.error) + r.stderr + r.logs.join("\n")).not.toContain(canary) + }) + +for (const policy of [null, []] as const) + test((policy === null ? "inherit" : "exact") + " preserves stdout stderr and nonzero status", async () => { + const r = await run({ + name: policy === null ? "inherit-exit7" : "exact-exit7", + policy, + command: "printf stdout; printf stderr >&2; exit 7", + }) + expect(r.data.outcome.error).toBeUndefined() + expect(r.data.outcome.result.metadata.exit).toBe(7) + expect(r.data.outcome.result.output).toContain("stdout") + expect(r.data.outcome.result.output).toContain("stderr") + }) +for (const policy of [null, []] as const) + test((policy === null ? "inherit" : "exact") + " preserves shell launch errors", async () => { + const r = await run({ + name: policy === null ? "inherit-launch-error" : "exact-launch-error", + policy, + shell: "/owned-does-not-exist/bash", + command: "printf unreachable", + }) + expect(r.data.outcome.error).toBeDefined() + expect(r.data.outcome.error.message).toMatch(/ENOENT|No such file|not found|posix_spawn/i) + expect(r.data.outcome.result).toBeUndefined() + }) + +for (const action of ["fetch", "install"] as const) + test("network guard rejects owned " + action + " negative control", async () => { + const r = await run({ name: "guard-negative-" + action, policy: [], guardNegative: action }) + expect(r.data.guardBlocked).toBe(true) + expect(r.attempts).toHaveLength(1) + expect(r.attempts[0].allowed).toBe(false) + }) + +for (const policy of [null, []] as const) + test( + (policy === null ? "inherit" : "exact") + " pre-aborted context settles without losing the exit event", + async () => { + const r = await run({ + name: policy === null ? "inherit-pre-aborted" : "exact-pre-aborted", + policy, + preAbort: true, + command: "printf owned-preabort", + }) + expect(r.data.outcome.error).toBeUndefined() + expect(r.data.outcome.result.output).toContain("User aborted") + }, + ) +for (const policy of [null, []] as const) + test( + (policy === null ? "inherit" : "exact") + " rapid exits drain both output streams and retain status", + async () => { + const r = await run({ + name: policy === null ? "inherit-rapid-exit" : "exact-rapid-exit", + policy, + repeat: 20, + command: "printf stdout; printf stderr >&2; exit 7", + }) + expect(r.data.outcome.error).toBeUndefined() + expect(r.data.outcome.results).toHaveLength(20) + for (const result of r.data.outcome.results) { + expect(result.metadata.exit).toBe(7) + expect(result.output).toContain("stdout") + expect(result.output).toContain("stderr") + } + }, + ) + +test("real hook failure stops before final shell without unfiltered fallback", async () => { + const r = await run({ name: "hook-throws", policy: ["PATH"], hook: 'throw new Error("Owned hook failure")' }) + expect(r.data.outcome.error.message).toBe("Owned hook failure") + expect(r.effects.map((e) => e.kind)).toEqual(["preflight", "permission", "hook"]) +}) diff --git a/packages/cli/test/tool/fixture/bash-env-case.ts b/packages/cli/test/tool/fixture/bash-env-case.ts new file mode 100644 index 00000000..7c7bf833 --- /dev/null +++ b/packages/cli/test/tool/fixture/bash-env-case.ts @@ -0,0 +1,104 @@ +import fs from "node:fs/promises" +import { appendFileSync } from "node:fs" +import path from "node:path" +import { BashTool } from "../../../src/tool/bash" +import { BashEnvPolicy } from "../../../src/util/bash-env-policy" +import { Instance } from "../../../src/project/instance" +import { PermissionNext } from "../../../src/permission/next" +import { Log } from "../../../src/util/log" + +const input = JSON.parse(await fs.readFile(process.env.BASH_ENV_INPUT!, "utf8")) +const record = (value: unknown) => appendFileSync(process.env.BASH_ENV_LEDGER!, JSON.stringify(value) + "\n") +if (input.guardNegative) { + let guardBlocked = false + try { + if (input.guardNegative === "fetch") await fetch("https://owned-denied.invalid/fixture") + else Bun.spawn([process.execPath, "install"]) + } catch { + guardBlocked = true + } + console.log(JSON.stringify({ guardBlocked })) + process.exit(0) +} +if (input.policy !== null) BashEnvPolicy.initialize({ "bash-env-allowlist": JSON.stringify(input.policy) }) +await Log.init({ print: false, dev: true, level: "DEBUG" }) +const controller = new AbortController() +if (input.preAbort) controller.abort() +const ctx = { + sessionID: "ses_owned", + messageID: "", + callID: "owned", + agent: "build", + abort: controller.signal, + messages: [], + metadata: (data: { metadata?: { output?: string } }) => { + if (input.cancel && data.metadata?.output?.includes("LIFECYCLE_READY")) controller.abort() + }, + ask: async (request: Omit) => { + record({ kind: "permission", permission: request.permission }) + await PermissionNext.ask({ + ...request, + sessionID: "ses_owned", + ruleset: [{ permission: "*", pattern: "*", action: input.deny ? "deny" : "allow" }], + }) + }, +} +const before = { gh: process.env.GH_TOKEN, provider: process.env.CUSTOM_GATEWAY_KEY, allowed: process.env.ALLOWED } +const outcome = await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const tool = await BashTool.init() + + let command = input.command + if (input.residual) { + const file = path.join(process.cwd(), "parent-credential") + await fs.writeFile(file, "fake-owned-file-credential") + const residual = path.join(process.cwd(), "residual.ts") + await fs.writeFile( + residual, + `import fs from "node:fs"; const rows=fs.readFileSync("/proc/${process.pid}/environ","utf8").split("\\0");console.log(JSON.stringify({childGh:process.env.GH_TOKEN??null,parentGh:rows.find(v=>v.startsWith("GH_TOKEN=")),credential:fs.readFileSync(${JSON.stringify(file)},"utf8")}))`, + ) + command = `${JSON.stringify(process.execPath)} ${JSON.stringify(residual)}` + } + if (input.cancel || input.timeout !== undefined) { + const lifecycle = path.join(process.cwd(), "lifecycle.ts") + await fs.writeFile( + lifecycle, + `import{appendFileSync}from"node:fs";const record=v=>appendFileSync(${JSON.stringify(process.env.BASH_ENV_LEDGER)},JSON.stringify(v)+"\\n");const child=Bun.spawn([process.execPath,"--no-env-file","-e","setInterval(()=>{},1000)"],{env:{PATH:"/usr/bin:/bin"},stdin:"ignore",stdout:"ignore",stderr:"ignore"});record({kind:"lifecycle-start",pid:process.pid,grandchild:child.pid});process.on("SIGTERM",async()=>{child.kill("SIGTERM");await child.exited;record({kind:"lifecycle-reaped"});process.exit(0)});console.log("LIFECYCLE_READY");setInterval(()=>{},1000)`, + ) + command = `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycle)}` + } + try { + if (input.repeat) { + const results = [] + for (let i = 0; i < input.repeat; i++) + results.push(await tool.execute({ command, description: "Owned rapid exit probe" }, ctx)) + return { results } + } + const result = await tool.execute( + { + command, + description: "Owned environment probe", + ...(input.timeout !== undefined ? { timeout: input.timeout } : {}), + }, + ctx, + ) + return { result } + } catch (error) { + return { + error: { + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + cause: error instanceof Error ? String(error.cause) : undefined, + code: (error as { code?: string }).code, + }, + } + } + }, +}) +const after = { gh: process.env.GH_TOKEN, provider: process.env.CUSTOM_GATEWAY_KEY, allowed: process.env.ALLOWED } +await new Promise((resolve) => setTimeout(resolve, 80)) +console.log(JSON.stringify({ outcome, before, after, policy: BashEnvPolicy.current() })) +await Instance.disposeAll() +process.exit(0) diff --git a/packages/cli/test/util/bash-env-policy.test.ts b/packages/cli/test/util/bash-env-policy.test.ts new file mode 100644 index 00000000..dfa5b8ee --- /dev/null +++ b/packages/cli/test/util/bash-env-policy.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { spawn } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { BashEnvPolicy } from "../../src/util/bash-env-policy" + +const canary = "FAKE_POLICY_CANARY_b19f" +test("unset defaults to frozen inheritance", () => { + expect(BashEnvPolicy.current()).toEqual({ mode: "inherit" }) + expect(Object.isFrozen(BashEnvPolicy.current())).toBe(true) + expect(BashEnvPolicy.parse(undefined)).toEqual({ mode: "inherit" }) +}) +test("empty list is an explicit frozen policy", () => { + const value = BashEnvPolicy.parse("[]") + expect(value).toEqual({ mode: "allowlist", names: [] }) + expect(Object.isFrozen(value)).toBe(true) + if (value.mode !== "allowlist") throw new Error("Expected explicit policy") + expect(Object.isFrozen(value.names)).toBe(true) +}) +test("POSIX deduplicates exact identity without case folding", () => { + expect(BashEnvPolicy.parse('["PATH","PATH","Path","ProgramFiles(x86)"]')).toEqual({ + mode: "allowlist", + names: ["PATH", "Path", "ProgramFiles(x86)"], + }) +}) +test("pure Windows identity comparison is case insensitive (not native launch proof)", () => { + expect(BashEnvPolicy.parse('["Path","PATH","path","ProgramFiles(x86)"]', "win32")).toEqual({ + mode: "allowlist", + names: ["Path", "ProgramFiles(x86)"], + }) +}) +for (const [name, value] of [ + ["empty", ""], + ["malformed", `{${canary}`], + ["object", JSON.stringify({ [canary]: true })], + ["null", "null"], + ["scalar", JSON.stringify(canary)], + ["number", "3"], + ["boolean", "false"], + ["array-value", ["PATH"]], + ["empty-name", '[""]'], + ["nul", `["BAD\\u0000${canary}"]`], + ["equals", `["BAD=${canary}"]`], + ["star", `["*${canary}"]`], + ["question", '["A?B"]'], + ["square", '["A[B]"]'], + ["curly", '["A{B}"]'], + ["wrong-element", `["${canary}",null]`], +] as [string, unknown][]) + test(name + " reports only safe classification", () => { + let error: unknown + try { + BashEnvPolicy.parse(value) + } catch (caught) { + error = caught + } + expect(error).toBeInstanceOf(BashEnvPolicy.ConfigError) + expect((error as BashEnvPolicy.ConfigError).code).toBe("BASH_ENV_CONFIGURATION") + expect((error as Error).cause).toBeUndefined() + expect([String(error), (error as Error).stack, JSON.stringify(error)].join("\n")).not.toContain(canary) + }) + +async function isolated(name: string, script: string) { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-bash-env-policy-")) + const source = path.resolve(import.meta.dir, "../../src/util/bash-env-policy.ts") + const file = path.join(cwd, "policy.ts") + await fs.writeFile(file, `import { BashEnvPolicy as p } from ${JSON.stringify(source)}\n${script}\n`) + const child = spawn(process.execPath, ["run", "--no-install", "--no-env-file", file], { + cwd, + env: { PATH: path.dirname(process.execPath) + ":/usr/bin:/bin", HOME: cwd, TZ: "UTC" }, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "", + stderr = "" + child.stdout!.on("data", (value) => (stdout += value.toString())) + child.stderr!.on("data", (value) => (stderr += value.toString())) + const timer = setTimeout(() => child.kill("SIGKILL"), 5000) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => clearTimeout(timer)) + const result = { name, code, stdout, stderr } + if (process.env.BASH_ENV_EVIDENCE) { + await fs.mkdir(process.env.BASH_ENV_EVIDENCE, { recursive: true }) + await fs.writeFile( + path.join(process.env.BASH_ENV_EVIDENCE, `unit-${name}.json`), + JSON.stringify(result, null, 2) + "\n", + ) + } + expect(code, stderr).toBe(0) + return JSON.parse(stdout) +} +describe("isolated internal host initializer", () => { + test("direct host default ignores the legacy environment key", async () => { + expect( + await isolated( + "direct-default", + 'process.env.AICTRL_BASH_ENV_ALLOWLIST="[]"; console.log(JSON.stringify(p.current()))', + ), + ).toEqual({ mode: "inherit" }) + }) + test("write-once snapshot cannot be widened or mutated", async () => { + const r = await isolated( + "write-once", + `const options = { "bash-env-allowlist": '["PATH"]' }; const first = p.initialize(options); options["bash-env-allowlist"]='["HOME"]'; process.env.AICTRL_BASH_ENV_ALLOWLIST='["HOME"]'; let code; try {p.initialize(options)} catch(e) {code=e.code}; console.log(JSON.stringify({first,current:p.current(),code,frozen:Object.isFrozen(first),namesFrozen:Object.isFrozen(first.names),same:p.initialize({"bash-env-allowlist":'["PATH"]'})===first}))`, + ) + expect(r).toEqual({ + first: { mode: "allowlist", names: ["PATH"] }, + current: { mode: "allowlist", names: ["PATH"] }, + code: "BASH_ENV_CONFIGURATION", + frozen: true, + namesFrozen: true, + same: true, + }) + }) + test("default initialization cannot later acquire explicit authority", async () => { + expect( + await isolated( + "default-write-once", + `p.initialize({}); let code; try {p.initialize({"bash-env-allowlist":"[]"})}catch(e){code=e.code}; console.log(JSON.stringify({current:p.current(),code}))`, + ), + ).toEqual({ current: { mode: "inherit" }, code: "BASH_ENV_CONFIGURATION" }) + }) + for (const [name, options] of [ + ["undefined", '{"bash-env-allowlist":undefined}'], + ["repeated", '{"bash-env-allowlist":["[]","[]"]}'], + ["attach-empty", '{"bash-env-allowlist":"[]",attach:""}'], + ["attach-undefined", '{"bash-env-allowlist":"[]",attach:undefined}'], + ] as const) + test(name + " rejects explicit invalid presence", async () => { + expect( + await isolated( + name, + `let code; try { p.initialize(${options}) } catch(e) { code=e.code }; console.log(JSON.stringify({code,current:p.current()}))`, + ), + ).toEqual({ code: "BASH_ENV_CONFIGURATION", current: { mode: "inherit" } }) + }) +})