From 2731c2f8dd13f7f9e0c6bacbd3fb7482ecf9a479 Mon Sep 17 00:00:00 2001 From: haowu1234 <13258260125@163.com> Date: Wed, 10 Jun 2026 15:21:36 +0800 Subject: [PATCH] Add opt-in native subprocess sandbox --- src/autoresearch/state.ts | 9 + src/code-intelligence/codegraph-engine.ts | 11 +- src/code-intelligence/hub.ts | 2 +- src/config/config.ts | 3 +- src/config/defaults.ts | 8 + src/rtk/command.ts | 61 ++- src/sandbox/backends/linux-bubblewrap.ts | 205 ++++++++ .../backends/linux-platform-baseline.ts | 15 + .../backends/macos-platform-baseline.ts | 26 ++ src/sandbox/backends/macos-seatbelt.ts | 121 +++++ src/sandbox/backends/noop.ts | 24 + src/sandbox/metadata.ts | 29 ++ src/sandbox/planner.ts | 91 ++++ src/sandbox/policy.ts | 144 ++++++ src/sandbox/runner.ts | 266 +++++++++++ src/sandbox/types.ts | 76 +++ src/tools/autoresearch-tools.ts | 34 +- src/tools/code-intelligence.ts | 62 ++- src/tools/process-tools.ts | 28 +- src/tools/workspace-tools.ts | 61 +-- src/tui/app.ts | 118 ++++- src/tui/composer.ts | 7 +- src/tui/slash.ts | 21 + src/types.ts | 11 + src/util/fs.ts | 35 +- test/sandbox.test.ts | 442 ++++++++++++++++++ test/slash.test.ts | 29 +- test/tui-composer.test.ts | 12 + test/tui-omni.test.ts | 21 + 29 files changed, 1870 insertions(+), 102 deletions(-) create mode 100644 src/sandbox/backends/linux-bubblewrap.ts create mode 100644 src/sandbox/backends/linux-platform-baseline.ts create mode 100644 src/sandbox/backends/macos-platform-baseline.ts create mode 100644 src/sandbox/backends/macos-seatbelt.ts create mode 100644 src/sandbox/backends/noop.ts create mode 100644 src/sandbox/metadata.ts create mode 100644 src/sandbox/planner.ts create mode 100644 src/sandbox/policy.ts create mode 100644 src/sandbox/runner.ts create mode 100644 src/sandbox/types.ts create mode 100644 test/sandbox.test.ts diff --git a/src/autoresearch/state.ts b/src/autoresearch/state.ts index 965d646..39cb3d7 100644 --- a/src/autoresearch/state.ts +++ b/src/autoresearch/state.ts @@ -21,6 +21,7 @@ export interface AutoresearchRun { parsed_metrics: Record; parsed_primary: number | null; asi: JsonObject; + sandbox?: JsonObject; completed_at: string; } @@ -34,6 +35,7 @@ export interface HarnessValidation { output_resource_uri?: string; timed_out?: boolean; output_truncated?: boolean; + sandbox?: JsonObject; message: string; validated_at: string; } @@ -436,6 +438,7 @@ function cloneHarnessValidation(status: HarnessValidation): HarnessValidation { return { ...status, parsed_metrics: { ...status.parsed_metrics }, + sandbox: status.sandbox ? { ...status.sandbox } : undefined, }; } @@ -460,6 +463,7 @@ function parseRun(value: unknown): AutoresearchRun | undefined { parsed_metrics: numericRecord(data.parsed_metrics), parsed_primary: typeof data.parsed_primary === "number" && Number.isFinite(data.parsed_primary) ? data.parsed_primary : null, asi: objectRecord(data.asi), + sandbox: objectRecordOrUndefined(data.sandbox), completed_at: typeof data.completed_at === "string" ? data.completed_at : "", }; } @@ -484,6 +488,7 @@ function parseHarnessValidation(value: unknown): HarnessValidation | undefined { output_resource_uri: typeof data.output_resource_uri === "string" ? data.output_resource_uri : undefined, timed_out: data.timed_out === true ? true : undefined, output_truncated: data.output_truncated === true ? true : undefined, + sandbox: objectRecordOrUndefined(data.sandbox), message, validated_at: typeof data.validated_at === "string" ? data.validated_at : "", }; @@ -603,6 +608,10 @@ function objectRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; } +function objectRecordOrUndefined(value: unknown): JsonObject | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined; +} + function positiveIntOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } diff --git a/src/code-intelligence/codegraph-engine.ts b/src/code-intelligence/codegraph-engine.ts index f637f96..f994ddf 100644 --- a/src/code-intelligence/codegraph-engine.ts +++ b/src/code-intelligence/codegraph-engine.ts @@ -155,6 +155,7 @@ export class CodeGraphContextEngine { constructor( private readonly config: ContextEngineConfig, private readonly workspace: WorkspaceIdentity, + private readonly agentConfig?: VllmAgentConfig, ) { this.statusSnapshot = this.enabled() ? { provider: "codegraph", state: "idle", watcher: "inactive" } @@ -247,7 +248,7 @@ export class CodeGraphContextEngine { private async runIndexLifecycle(reason: string, options: { force?: boolean; signal?: AbortSignal }): Promise { this.update({ provider: "codegraph", state: "indexing", phase: "initializing", current: undefined, total: undefined, error: undefined, reason }); try { - await ensureCodeGraphIgnored(this.workspace.root); + await ensureCodeGraphIgnored(this.workspace, this.agentConfig); const { CodeGraph } = loadCodeGraphModule(); const initialized = CodeGraph.isInitialized(this.workspace.root); const cg = initialized ? await CodeGraph.open(this.workspace.root) : await CodeGraph.init(this.workspace.root, { index: false }); @@ -772,8 +773,12 @@ function languagesFromStats(stats: CodeGraphStats): string[] | undefined { .sort(); } -async function ensureCodeGraphIgnored(workspaceRoot: string): Promise { - const result = await runSmallCommand("git", ["rev-parse", "--git-dir"], workspaceRoot, 2000); +async function ensureCodeGraphIgnored(workspace: WorkspaceIdentity, config?: VllmAgentConfig): Promise { + if (config?.sandbox.mode && config.sandbox.mode !== "off") { + return; + } + const workspaceRoot = workspace.root; + const result = await runSmallCommand("git", ["rev-parse", "--git-dir"], workspaceRoot, 2000, config ? { config, workspace } : undefined); if (result.code !== 0 || !result.stdout.trim()) { return; } diff --git a/src/code-intelligence/hub.ts b/src/code-intelligence/hub.ts index 2e31c19..c0a4904 100644 --- a/src/code-intelligence/hub.ts +++ b/src/code-intelligence/hub.ts @@ -27,7 +27,7 @@ export class CodeIntelligenceHub { private readonly config: VllmAgentConfig, private readonly workspace: WorkspaceIdentity, ) { - this.codegraph = new CodeGraphContextEngine(normalizeContextEngineConfig(config), workspace); + this.codegraph = new CodeGraphContextEngine(normalizeContextEngineConfig(config), workspace, config); } dispose(): void { diff --git a/src/config/config.ts b/src/config/config.ts index 318494a..51cd945 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -222,7 +222,7 @@ function stringValue(value: unknown): string | undefined { } function pruneConfig(config: VllmAgentConfig): void { - pruneKeys(config, ["workspace", "model_setup", "model_retry", "omni", "permissions", "context", "skills", "web_search", "rtk", "daemon"]); + pruneKeys(config, ["workspace", "model_setup", "model_retry", "omni", "permissions", "sandbox", "context", "skills", "web_search", "rtk", "daemon"]); pruneKeys(config.workspace, ["root"]); pruneKeys(config.model_setup, ["mode", "provider", "provider_id", "profile", "router", "base_url", "model", "api_key_ref", "api_key", "headers", "context_window"]); pruneKeys(config.model_retry, [ @@ -251,6 +251,7 @@ function pruneConfig(config: VllmAgentConfig): void { for (const policy of Object.values(config.permissions?.workspaces ?? {})) { pruneKeys(policy, ["mode", "custom"]); } + pruneKeys(config.sandbox, ["mode", "backend", "network", "fail_if_unavailable", "extra_writable_roots", "env_passthrough"]); pruneKeys(config.context, ["compression_threshold", "context_window", "protected_recent_loops", "force_compression", "engine"]); pruneKeys(config.context?.engine, ["provider", "startup", "require_ready_before_chat", "watch"]); pruneKeys(config.skills, ["enabled", "managed_installs"]); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index dde3c47..dce0f86 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -21,6 +21,14 @@ export const DEFAULT_CONFIG: VllmAgentConfig = { permissions: { mode: "full_access", }, + sandbox: { + mode: "off", + backend: "auto", + network: "restricted", + fail_if_unavailable: true, + extra_writable_roots: [], + env_passthrough: [], + }, context: { compression_threshold: 0.8, context_window: 32_768, diff --git a/src/rtk/command.ts b/src/rtk/command.ts index bd7e8a5..2ae1b7e 100644 --- a/src/rtk/command.ts +++ b/src/rtk/command.ts @@ -1,10 +1,12 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { ensureDir } from "../util/fs.js"; -import type { VllmAgentConfig } from "../types.js"; +import type { VllmAgentConfig, WorkspaceIdentity } from "../types.js"; import type { SessionStore } from "../session/store.js"; import { resolveRtkStatus, rtkDbPath, rtkEnv, type RtkRuntime } from "./manager.js"; import { readRtkCommandStats, type RtkCommandStats } from "./stats.js"; +import { runSandboxedProcess } from "../sandbox/runner.js"; +import type { SandboxExecutionInfo } from "../sandbox/types.js"; export interface RtkShellCommandOptions { config: VllmAgentConfig; @@ -15,6 +17,7 @@ export interface RtkShellCommandOptions { tool_name: string; command: string; cwd: string; + workspace: WorkspaceIdentity; env?: NodeJS.ProcessEnv; timeout_ms: number; } @@ -27,6 +30,7 @@ export interface RtkShellCommandResult { command: string; rewritten_command?: string; rtk?: RtkCommandStats; + sandbox?: SandboxExecutionInfo; } interface PreparedRtkCommand { @@ -40,7 +44,7 @@ interface PreparedRtkCommand { export async function runRtkAwareShellCommand(options: RtkShellCommandOptions): Promise { const prepared = await prepareCommand(options); - const result = await runShell(prepared.command, options.cwd, prepared.env, options.timeout_ms); + const result = await runShell(prepared.command, options.cwd, prepared.env, options.timeout_ms, options.config, options.workspace, prepared.original_command, prepared.rewritten_command); const rtk = await recordRtkSavings(options, prepared); return { ...result, @@ -146,39 +150,26 @@ async function recordRtkSavings(options: RtkShellCommandOptions, prepared: Prepa return stats; } -function runShell(command: string, cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number): Promise> { - return new Promise((resolve) => { - const child = spawn(command, { - cwd, - env, - shell: true, - }); - let stdout = ""; - let stderr = ""; - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, 2000).unref(); - }, timeoutMs); - child.stdout.on("data", (chunk) => { - stdout += String(chunk); - }); - child.stderr.on("data", (chunk) => { - stderr += String(chunk); - }); - child.on("close", (code) => { - clearTimeout(timeout); - resolve({ code, stdout, stderr, timed_out: timedOut }); - }); - child.on("error", (error) => { - clearTimeout(timeout); - resolve({ code: 127, stdout, stderr: error.message, timed_out: timedOut }); - }); +async function runShell( + command: string, + cwd: string, + env: NodeJS.ProcessEnv, + timeoutMs: number, + config: VllmAgentConfig, + workspace: WorkspaceIdentity, + originalCommand: string, + rewrittenCommand?: string, +): Promise> { + return await runSandboxedProcess({ + config, + workspace, + command, + shell: true, + cwd, + env, + timeoutMs, + originalCommand, + rewrittenCommand, }); } diff --git a/src/sandbox/backends/linux-bubblewrap.ts b/src/sandbox/backends/linux-bubblewrap.ts new file mode 100644 index 0000000..8700f74 --- /dev/null +++ b/src/sandbox/backends/linux-bubblewrap.ts @@ -0,0 +1,205 @@ +import { spawn } from "node:child_process"; +import { constants, promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SandboxAvailability, SandboxBackendAdapter, SandboxBuildRequest, SandboxInvocation, SandboxPolicy } from "../types.js"; +import { sandboxInfo } from "../metadata.js"; +import { linuxBubblewrapPlatformBaselineArgs, type LinuxBubblewrapProcMode } from "./linux-platform-baseline.js"; + +export class LinuxBubblewrapBackend implements SandboxBackendAdapter { + readonly id = "linux_bubblewrap" as const; + + async available(): Promise { + if (process.platform !== "linux") { + return { available: false, reason: "bubblewrap sandbox is only available on Linux" }; + } + const executable = await findExecutable("bwrap"); + if (!executable) { + return { available: false, reason: "bubblewrap (bwrap) was not found on PATH" }; + } + const probe = await bubblewrapProcMode(executable); + return probe.available ? { available: true, executable } : { available: false, reason: probe.reason }; + } + + async build(policy: SandboxPolicy, request: SandboxBuildRequest): Promise { + const executable = (await findExecutable("bwrap")) ?? "bwrap"; + const command = request.shell ? ["/bin/sh", "-c", request.command] : [request.command, ...(request.args ?? [])]; + await ensureAgentWritableRoots(policy); + const readOnlyMounts = await readOnlyMountsForPolicy(policy); + const procMode = (await bubblewrapProcMode(executable)).mode ?? "procfs"; + const args = bubblewrapArgs(policy, command, readOnlyMounts, { procMode }); + return { + command: executable, + args, + shell: false, + env: request.env, + info: sandboxInfo(policy, request), + }; + } + + explainFailure(reason: string): string { + return `Linux sandbox unavailable: ${reason}. Install bubblewrap or run /sandbox off to disable OS sandboxing.`; + } +} + +export interface BubblewrapReadOnlyMount { + source: string; + dest: string; +} + +export interface BubblewrapArgsOptions { + procMode?: LinuxBubblewrapProcMode; +} + +export function bubblewrapArgs(policy: SandboxPolicy, command: string[], readOnlyMounts?: BubblewrapReadOnlyMount[], options: BubblewrapArgsOptions = {}): string[] { + const args = linuxBubblewrapPlatformBaselineArgs(options.procMode); + if (policy.network === "restricted") { + args.push("--unshare-net"); + } + for (const root of policy.writableRoots) { + args.push("--bind", root, root); + } + for (const mount of readOnlyMounts ?? defaultReadOnlyMounts(policy)) { + args.push("--ro-bind", mount.source, mount.dest); + } + for (const root of policy.agentWritableRoots) { + args.push("--bind", root, root); + } + args.push("--chdir", policy.cwd, "--", ...command); + return args; +} + +export async function readOnlyMountsForPolicy(policy: SandboxPolicy): Promise { + return readOnlyMountsFor([...policy.readOnlyRoots, ...policy.protectedWritePaths], policy.agentWritableRoots); +} + +async function readOnlyMountsFor(paths: string[], agentWritableRoots: string[] = []): Promise { + const syntheticDir = await syntheticEmptyDir(); + const out: BubblewrapReadOnlyMount[] = []; + const writableCarveOuts = uniquePaths(agentWritableRoots); + const mountedReadOnlyDests: string[] = []; + for (const target of uniquePaths(paths).sort(comparePathDepth)) { + try { + await fs.access(target, constants.F_OK); + out.push({ source: target, dest: target }); + mountedReadOnlyDests.push(target); + } catch { + if (hasReadOnlyAncestor(target, mountedReadOnlyDests, writableCarveOuts)) { + continue; + } + out.push({ source: syntheticDir, dest: target }); + mountedReadOnlyDests.push(target); + } + } + return out; +} + +function hasReadOnlyAncestor(target: string, readOnlyDests: string[], writableCarveOuts: string[]): boolean { + if (writableCarveOuts.some((root) => isStrictDescendant(target, root))) { + return false; + } + return readOnlyDests.some((root) => isStrictDescendant(target, root)); +} + +function isStrictDescendant(target: string, root: string): boolean { + const relative = path.relative(root, target); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function comparePathDepth(left: string, right: string): number { + return pathDepth(left) - pathDepth(right); +} + +function pathDepth(value: string): number { + return path.resolve(value).split(path.sep).filter(Boolean).length; +} + +function defaultReadOnlyMounts(policy: SandboxPolicy): BubblewrapReadOnlyMount[] { + return uniquePaths([...policy.readOnlyRoots, ...policy.protectedWritePaths]).map((target) => ({ source: target, dest: target })); +} + +async function ensureAgentWritableRoots(policy: SandboxPolicy): Promise { + if (policy.mode !== "workspace_write") { + return; + } + for (const root of policy.agentWritableRoots) { + await fs.mkdir(root, { recursive: true }); + } +} + +async function syntheticEmptyDir(): Promise { + const dir = path.join(os.tmpdir(), "inferoa-bwrap-empty-dir"); + await fs.mkdir(dir, { recursive: true }); + return dir; +} + +async function findExecutable(command: string): Promise { + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) { + continue; + } + const candidate = path.join(dir, command); + try { + await fs.access(candidate, constants.X_OK); + return candidate; + } catch { + // Keep searching PATH. + } + } + return undefined; +} + +interface BubblewrapProcProbe { + available: boolean; + mode?: LinuxBubblewrapProcMode; + reason?: string; +} + +const procModeCache = new Map>(); + +function bubblewrapProcMode(executable: string): Promise { + let cached = procModeCache.get(executable); + if (!cached) { + cached = detectBubblewrapProcMode(executable); + procModeCache.set(executable, cached); + } + return cached; +} + +async function detectBubblewrapProcMode(executable: string): Promise { + const procfs = await runBwrapProbe(executable, linuxBubblewrapPlatformBaselineArgs("procfs")); + if (procfs.code === 0) { + return { available: true, mode: "procfs" }; + } + const readonlyBind = await runBwrapProbe(executable, linuxBubblewrapPlatformBaselineArgs("readonly_bind")); + if (readonlyBind.code === 0) { + return { available: true, mode: "readonly_bind" }; + } + return { available: false, reason: readonlyBind.stderr || procfs.stderr || "bubblewrap baseline probe failed" }; +} + +function runBwrapProbe(executable: string, baselineArgs: string[]): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(executable, [...baselineArgs, "--", "true"], { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ code: 124, stderr: "bubblewrap baseline probe timed out" }); + }, 3000); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (code) => { + clearTimeout(timeout); + resolve({ code, stderr: stderr.trim() }); + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolve({ code: 127, stderr: error.message }); + }); + }); +} + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths.map((item) => path.resolve(item)))]; +} diff --git a/src/sandbox/backends/linux-platform-baseline.ts b/src/sandbox/backends/linux-platform-baseline.ts new file mode 100644 index 0000000..600a314 --- /dev/null +++ b/src/sandbox/backends/linux-platform-baseline.ts @@ -0,0 +1,15 @@ +export type LinuxBubblewrapProcMode = "procfs" | "readonly_bind"; + +export function linuxBubblewrapPlatformBaselineArgs(procMode: LinuxBubblewrapProcMode = "procfs"): string[] { + return [ + "--unshare-user", + "--unshare-pid", + "--die-with-parent", + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + ...(procMode === "procfs" ? ["--proc", "/proc"] : ["--ro-bind", "/proc", "/proc"]), + ]; +} diff --git a/src/sandbox/backends/macos-platform-baseline.ts b/src/sandbox/backends/macos-platform-baseline.ts new file mode 100644 index 0000000..950b283 --- /dev/null +++ b/src/sandbox/backends/macos-platform-baseline.ts @@ -0,0 +1,26 @@ +export function macosSeatbeltPlatformBaseline(): string[] { + return [ + "(allow file-read* file-test-existence file-write-data", + ' (literal "/dev/null")', + ' (literal "/dev/zero"))', + "(allow file-read* file-test-existence", + ' (literal "/dev/random")', + ' (literal "/dev/urandom"))', + '(allow file-read-data file-test-existence file-write-data (subpath "/dev/fd"))', + '(allow file-read* file-write* (literal "/dev/tty"))', + '(allow file-read-metadata (literal "/dev"))', + '(allow file-read-metadata (regex "^/dev/.*$"))', + '(allow file-read* file-write* (regex "^/dev/ttys[0-9]+$"))', + '(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))', + '(allow file-ioctl (regex "^/dev/ttys[0-9]+$"))', + "(allow pseudo-tty)", + '(allow file-read* (subpath "/etc"))', + '(allow file-read* (subpath "/private/etc"))', + '(allow file-read* (subpath "/System/Library"))', + '(allow file-read* (subpath "/Library/Preferences"))', + '(allow file-read* (subpath "/usr/lib"))', + '(allow file-read* (subpath "/usr/libexec"))', + '(allow file-read* (subpath "/opt/homebrew/lib"))', + '(allow file-read* (subpath "/usr/local/lib"))', + ]; +} diff --git a/src/sandbox/backends/macos-seatbelt.ts b/src/sandbox/backends/macos-seatbelt.ts new file mode 100644 index 0000000..fde6117 --- /dev/null +++ b/src/sandbox/backends/macos-seatbelt.ts @@ -0,0 +1,121 @@ +import { constants, promises as fs } from "node:fs"; +import path from "node:path"; +import type { SandboxAvailability, SandboxBackendAdapter, SandboxBuildRequest, SandboxInvocation, SandboxPolicy } from "../types.js"; +import { sandboxInfo } from "../metadata.js"; +import { macosSeatbeltPlatformBaseline } from "./macos-platform-baseline.js"; + +const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; + +export class MacosSeatbeltBackend implements SandboxBackendAdapter { + readonly id = "macos_seatbelt" as const; + + async available(): Promise { + if (process.platform !== "darwin") { + return { available: false, reason: "macOS Seatbelt sandbox is only available on macOS" }; + } + try { + await fs.access(SANDBOX_EXEC, constants.X_OK); + return { available: true, executable: SANDBOX_EXEC }; + } catch { + return { available: false, reason: `${SANDBOX_EXEC} is not executable on this system` }; + } + } + + async build(policy: SandboxPolicy, request: SandboxBuildRequest): Promise { + const command = request.shell ? ["/bin/sh", "-c", request.command] : [request.command, ...(request.args ?? [])]; + await ensureAgentWritableRoots(policy); + const seatbeltPathsPolicy = { + ...policy, + writableRoots: await seatbeltPathVariantsFor(policy.writableRoots), + readOnlyRoots: await seatbeltPathVariantsFor(policy.readOnlyRoots), + protectedCreatePaths: await seatbeltPathVariantsFor(policy.protectedCreatePaths), + protectedWritePaths: await seatbeltPathVariantsFor(policy.protectedWritePaths), + agentWritableRoots: await seatbeltPathVariantsFor(policy.agentWritableRoots), + }; + return { + command: SANDBOX_EXEC, + args: ["-p", seatbeltPolicy(seatbeltPathsPolicy), "--", ...command], + shell: false, + env: request.env, + info: sandboxInfo(policy, request), + }; + } + + explainFailure(reason: string): string { + return `macOS sandbox unavailable: ${reason}`; + } +} + +async function seatbeltPathVariantsFor(paths: string[]): Promise { + const variants = await Promise.all(paths.map(seatbeltPathVariants)); + return [...new Set(variants.flat().map((item) => path.resolve(item)))]; +} + +async function seatbeltPathVariants(target: string): Promise { + const variants = [path.resolve(target)]; + try { + variants.push(await fs.realpath(target)); + return variants; + } catch { + // Seatbelt evaluates canonical vnode paths. For paths that do not exist + // yet, canonicalize the nearest existing parent and append the missing tail. + } + const missingParts: string[] = []; + let cursor = path.resolve(target); + while (cursor && cursor !== path.dirname(cursor)) { + missingParts.unshift(path.basename(cursor)); + cursor = path.dirname(cursor); + try { + variants.push(path.join(await fs.realpath(cursor), ...missingParts)); + return variants; + } catch { + // Keep walking upward. + } + } + return variants; +} + +export function seatbeltPolicy(policy: SandboxPolicy): string { + const lines = [ + "(version 1)", + "(allow default)", + ...macosSeatbeltPlatformBaseline(), + ]; + if (policy.writableRoots.length === 0) { + lines.push("(deny file-write*)"); + } else { + lines.push( + "(deny file-write*", + ` (require-all ${policy.writableRoots.map((root) => `(require-not (subpath ${seatbeltString(root)}))`).join(" ")})`, + ")", + ); + } + for (const protectedPath of policy.protectedWritePaths) { + lines.push(`(deny file-write* (literal ${seatbeltString(protectedPath)}))`); + lines.push(`(deny file-write* (subpath ${seatbeltString(protectedPath)}))`); + } + for (const createPath of policy.protectedCreatePaths) { + lines.push(`(deny file-write-create (literal ${seatbeltString(createPath)}))`); + } + for (const agentRoot of policy.agentWritableRoots) { + lines.push(`(allow file-write* (literal ${seatbeltString(agentRoot)}))`); + lines.push(`(allow file-write* (subpath ${seatbeltString(agentRoot)}))`); + } + if (policy.network === "restricted") { + lines.push("(deny network*)"); + } + return `${lines.join("\n")}\n`; +} + +async function ensureAgentWritableRoots(policy: SandboxPolicy): Promise { + if (policy.mode !== "workspace_write") { + return; + } + for (const root of policy.agentWritableRoots) { + await fs.mkdir(root, { recursive: true }); + } +} + +function seatbeltString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; +} diff --git a/src/sandbox/backends/noop.ts b/src/sandbox/backends/noop.ts new file mode 100644 index 0000000..7788fda --- /dev/null +++ b/src/sandbox/backends/noop.ts @@ -0,0 +1,24 @@ +import type { SandboxAvailability, SandboxBackendAdapter, SandboxBuildRequest, SandboxInvocation, SandboxPolicy } from "../types.js"; +import { sandboxInfo } from "../metadata.js"; + +export class NoopBackend implements SandboxBackendAdapter { + readonly id = "none" as const; + + async available(): Promise { + return { available: true }; + } + + async build(policy: SandboxPolicy, request: SandboxBuildRequest): Promise { + return { + command: request.command, + args: request.args ?? [], + shell: request.shell, + env: request.env, + info: sandboxInfo(policy, request), + }; + } + + explainFailure(reason: string): string { + return reason; + } +} diff --git a/src/sandbox/metadata.ts b/src/sandbox/metadata.ts new file mode 100644 index 0000000..95c9c40 --- /dev/null +++ b/src/sandbox/metadata.ts @@ -0,0 +1,29 @@ +import type { SandboxBuildRequest, SandboxExecutionInfo, SandboxPolicy } from "./types.js"; +import { gitSubcommand } from "./policy.js"; + +export function sandboxInfo(policy: SandboxPolicy, request: SandboxBuildRequest): SandboxExecutionInfo { + const command = request.originalCommand ?? displayCommand(request); + return { + backend: policy.backend, + mode: policy.mode, + network: policy.network, + workspace_root: policy.workspaceRoot, + cwd: policy.cwd, + command, + rewritten_command: request.rewrittenCommand, + blocked: false, + suspected_subcommand: gitSubcommand(command), + capabilities: policy.capabilities.length ? policy.capabilities : undefined, + }; +} + +function displayCommand(request: SandboxBuildRequest): string { + if (request.shell || !request.args?.length) { + return request.command; + } + return [request.command, ...request.args.map(shellQuoteForDisplay)].join(" "); +} + +function shellQuoteForDisplay(value: string): string { + return /^[A-Za-z0-9_/:=.,@%+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/src/sandbox/planner.ts b/src/sandbox/planner.ts new file mode 100644 index 0000000..5d6bbab --- /dev/null +++ b/src/sandbox/planner.ts @@ -0,0 +1,91 @@ +import type { VllmAgentConfig, WorkspaceIdentity } from "../types.js"; +import { resolveSandboxPolicy } from "./policy.js"; +import type { SandboxBackendAdapter, SandboxBuildRequest, SandboxCapability, SandboxInvocation } from "./types.js"; +import { LinuxBubblewrapBackend } from "./backends/linux-bubblewrap.js"; +import { MacosSeatbeltBackend } from "./backends/macos-seatbelt.js"; +import { NoopBackend } from "./backends/noop.js"; +import { sandboxInfoToJson, type SandboxExecutionInfo } from "./types.js"; + +export interface SandboxPlannerRequest extends SandboxBuildRequest { + config: VllmAgentConfig; + workspace: WorkspaceIdentity; + cwd: string; + capabilities?: SandboxCapability[]; +} + +export type SandboxPlannerResult = + | { ok: true; invocation: SandboxInvocation } + | { ok: false; info: SandboxExecutionInfo; stderr: string }; + +export async function planSandboxInvocation(request: SandboxPlannerRequest): Promise { + const policy = resolveSandboxPolicy({ + config: request.config, + workspace: request.workspace, + cwd: request.cwd, + capabilities: request.capabilities, + command: request.originalCommand ?? request.command, + }); + const backend = backendFor(policy.backend); + const availability = await backend.available(); + if (!availability.available && policy.mode !== "off" && policy.failIfUnavailable) { + const reason = backend.explainFailure(availability.reason ?? "backend is unavailable"); + return { + ok: false, + stderr: reason, + info: { + backend: policy.backend, + mode: policy.mode, + network: policy.network, + workspace_root: policy.workspaceRoot, + cwd: policy.cwd, + command: request.originalCommand ?? request.command, + rewritten_command: request.rewrittenCommand, + blocked: true, + block_stage: "backend_setup", + reason, + policy_rule: "sandbox_backend_unavailable", + suggested_action: policy.backend === "linux_bubblewrap" ? "Install bubblewrap or run /sandbox off." : "Run /sandbox off or choose a supported platform.", + capabilities: policy.capabilities.length ? policy.capabilities : undefined, + }, + }; + } + if (!availability.available) { + const fallbackPolicy = { ...policy, backend: "none" as const, mode: "off" as const }; + return { ok: true, invocation: await new NoopBackend().build(fallbackPolicy, request) }; + } + const env = policy.mode === "off" ? request.env : scrubSandboxEnv(request.env, request.config.sandbox?.env_passthrough ?? []); + return { ok: true, invocation: await backend.build(policy, { ...request, env }) }; +} + +export function sandboxBlockedJson(info: SandboxExecutionInfo) { + return sandboxInfoToJson(info); +} + +function backendFor(id: "none" | "macos_seatbelt" | "linux_bubblewrap"): SandboxBackendAdapter { + switch (id) { + case "macos_seatbelt": + return new MacosSeatbeltBackend(); + case "linux_bubblewrap": + return new LinuxBubblewrapBackend(); + case "none": + return new NoopBackend(); + } +} + +function scrubSandboxEnv(env: NodeJS.ProcessEnv, passthrough: string[]): NodeJS.ProcessEnv { + const pass = new Set(passthrough); + const out: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + continue; + } + if (pass.has(key) || !isSensitiveEnvKey(key)) { + out[key] = value; + } + } + return out; +} + +function isSensitiveEnvKey(key: string): boolean { + return /(API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH|WEBHOOK|OPENAI|ANTHROPIC|GEMINI|GOOGLE|GITHUB|GH_TOKEN|VLLM|INFEROA_OMNI)/i.test(key); +} diff --git a/src/sandbox/policy.ts b/src/sandbox/policy.ts new file mode 100644 index 0000000..eaec968 --- /dev/null +++ b/src/sandbox/policy.ts @@ -0,0 +1,144 @@ +import os from "node:os"; +import path from "node:path"; +import type { SandboxBackend, SandboxMode, SandboxNetworkMode, VllmAgentConfig, WorkspaceIdentity } from "../types.js"; +import type { SandboxBackendId, SandboxCapability, SandboxPolicy } from "./types.js"; + +const PROTECTED_AGENT_METADATA_NAMES = [".claude", ".codex", ".agents"]; +const INFEROA_AGENT_WRITABLE_NAMES = ["tmp", "exports", "artifacts", "evidence", "cache", "sandbox"]; +const INFEROA_PROTECTED_NAMES = ["config", "secrets", "credentials", "plugins", "skills", "sessions", "state"]; + +export interface ResolveSandboxPolicyOptions { + config: VllmAgentConfig; + workspace: WorkspaceIdentity; + cwd: string; + capabilities?: SandboxCapability[]; + command?: string; +} + +export function resolveSandboxPolicy(options: ResolveSandboxPolicyOptions): SandboxPolicy { + const sandbox = normalizeSandboxConfig(options.config); + const capabilities = new Set(options.capabilities ?? []); + for (const capability of inferCapabilities(options.command ?? "")) { + capabilities.add(capability); + } + const workspaceRoot = path.resolve(options.workspace.root); + const cwd = path.resolve(options.cwd); + const writableRoots = sandbox.mode === "workspace_write" ? uniquePaths([workspaceRoot, os.tmpdir(), ...sandbox.extra_writable_roots]) : []; + const metadataPolicy = sandbox.mode === "workspace_write" + ? protectedMetadataPolicy(workspaceRoot, capabilities.has("git_metadata_write")) + : { readOnlyRoots: [], protectedCreatePaths: [], protectedWritePaths: [], agentWritableRoots: [] }; + return { + mode: sandbox.mode, + backend: resolveBackend(sandbox.backend, sandbox.mode), + network: sandbox.network, + workspaceRoot, + cwd, + writableRoots, + readOnlyRoots: uniquePaths(metadataPolicy.readOnlyRoots), + protectedCreatePaths: uniquePaths(metadataPolicy.protectedCreatePaths), + protectedWritePaths: uniquePaths(metadataPolicy.protectedWritePaths), + agentWritableRoots: uniquePaths(metadataPolicy.agentWritableRoots), + envPassthrough: sandbox.env_passthrough, + failIfUnavailable: sandbox.fail_if_unavailable, + capabilities: [...capabilities], + }; +} + +export function normalizeSandboxConfig(config: VllmAgentConfig): { + mode: SandboxMode; + backend: SandboxBackend; + network: SandboxNetworkMode; + fail_if_unavailable: boolean; + extra_writable_roots: string[]; + env_passthrough: string[]; +} { + const sandbox = config.sandbox ?? {}; + return { + mode: sandbox.mode ?? "off", + backend: sandbox.backend ?? "auto", + network: sandbox.network ?? "restricted", + fail_if_unavailable: sandbox.fail_if_unavailable ?? true, + extra_writable_roots: Array.isArray(sandbox.extra_writable_roots) ? sandbox.extra_writable_roots.map((root) => path.resolve(String(root))) : [], + env_passthrough: Array.isArray(sandbox.env_passthrough) ? sandbox.env_passthrough.map(String) : [], + }; +} + +export function sandboxModeLabel(mode: SandboxMode): string { + switch (mode) { + case "off": + return "Off"; + case "read_only": + return "Read-only"; + case "workspace_write": + return "Workspace write"; + } +} + +export function inferCapabilities(command: string): SandboxCapability[] { + const git = gitSubcommand(command); + if (!git) { + return []; + } + if (["add", "branch", "checkout", "commit", "merge", "mv", "rebase", "reset", "restore", "rm", "switch", "tag"].includes(git)) { + if (isDangerousGitCommand(command)) { + return []; + } + return ["git_metadata_write"]; + } + return []; +} + +export function gitSubcommand(command: string): string | undefined { + const match = /(?:^|[;&|]\s*)(?:command\s+)?git(?:\s+-C\s+(?:"[^"]+"|'[^']+'|\S+))?\s+([A-Za-z][A-Za-z0-9_-]*)/.exec(command); + return match?.[1]?.toLowerCase(); +} + +export function isDangerousGitCommand(command: string): boolean { + return /\bgit(?:\s+-C\s+(?:"[^"]+"|'[^']+'|\S+))?\s+reset\s+--hard\b/.test(command) + || /\bgit(?:\s+-C\s+(?:"[^"]+"|'[^']+'|\S+))?\s+clean\s+-[^;\n]*[fd]/.test(command) + || /\bgit(?:\s+-C\s+(?:"[^"]+"|'[^']+'|\S+))?\s+push\s+[^;\n]*--force/.test(command); +} + +function resolveBackend(backend: SandboxBackend, mode: SandboxMode): SandboxBackendId { + if (mode === "off" || backend === "none") { + return "none"; + } + if (backend === "macos_seatbelt" || backend === "linux_bubblewrap") { + return backend; + } + if (process.platform === "darwin") { + return "macos_seatbelt"; + } + if (process.platform === "linux") { + return "linux_bubblewrap"; + } + return "none"; +} + +function protectedMetadataPolicy(workspaceRoot: string, allowGitMetadataWrite: boolean): { + readOnlyRoots: string[]; + protectedCreatePaths: string[]; + protectedWritePaths: string[]; + agentWritableRoots: string[]; +} { + const inferoaRoot = path.join(workspaceRoot, ".inferoa"); + const agentWritableRoots = INFEROA_AGENT_WRITABLE_NAMES.map((name) => path.join(inferoaRoot, name)); + const protectedMetadataRoots = [ + ...PROTECTED_AGENT_METADATA_NAMES.map((name) => path.join(workspaceRoot, name)), + inferoaRoot, + ]; + if (!allowGitMetadataWrite) { + protectedMetadataRoots.unshift(path.join(workspaceRoot, ".git")); + } + const inferoaControlPaths = INFEROA_PROTECTED_NAMES.map((name) => path.join(inferoaRoot, name)); + return { + readOnlyRoots: protectedMetadataRoots, + protectedCreatePaths: [...protectedMetadataRoots, ...inferoaControlPaths], + protectedWritePaths: [...protectedMetadataRoots, ...inferoaControlPaths], + agentWritableRoots, + }; +} + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths.map((item) => path.resolve(item)))]; +} diff --git a/src/sandbox/runner.ts b/src/sandbox/runner.ts new file mode 100644 index 0000000..d81bdef --- /dev/null +++ b/src/sandbox/runner.ts @@ -0,0 +1,266 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { VllmAgentConfig, WorkspaceIdentity } from "../types.js"; +import { planSandboxInvocation, sandboxBlockedJson } from "./planner.js"; +import type { SandboxCapability, SandboxExecutionInfo } from "./types.js"; + +export interface SandboxRunOptions { + config: VllmAgentConfig; + workspace: WorkspaceIdentity; + command: string; + args?: string[]; + shell?: boolean; + cwd: string; + env?: NodeJS.ProcessEnv; + timeoutMs: number; + stdin?: string | Buffer; + originalCommand?: string; + rewrittenCommand?: string; + capabilities?: SandboxCapability[]; +} + +export interface SandboxProcessResult { + code: number | null; + stdout: string; + stderr: string; + timed_out: boolean; + sandbox: SandboxExecutionInfo; +} + +export interface SandboxSpawnOptions extends Omit {} + +export async function runSandboxedProcess(options: SandboxRunOptions): Promise { + const planned = await planSandboxInvocation({ + config: options.config, + workspace: options.workspace, + command: options.command, + args: options.args, + shell: options.shell ?? false, + cwd: options.cwd, + env: options.env ?? process.env, + originalCommand: options.originalCommand, + rewrittenCommand: options.rewrittenCommand, + capabilities: options.capabilities, + }); + if (!planned.ok) { + return { code: 126, stdout: "", stderr: planned.stderr, timed_out: false, sandbox: planned.info }; + } + return await new Promise((resolve) => { + const child = spawn(planned.invocation.command, planned.invocation.args, { + cwd: options.cwd, + env: planned.invocation.env, + shell: planned.invocation.shell, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + killProcessTree(child, "SIGTERM"); + setTimeout(() => killProcessTree(child, "SIGKILL"), 2000).unref(); + }, options.timeoutMs); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (code) => { + clearTimeout(timeout); + const sandbox = runtimeSandboxInfo(planned.invocation.info, code, stderr); + resolve({ code, stdout, stderr, timed_out: timedOut, sandbox }); + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolve({ code: 127, stdout, stderr: error.message, timed_out: timedOut, sandbox: planned.invocation.info }); + }); + if (options.stdin !== undefined) { + child.stdin.end(options.stdin); + } else { + child.stdin.end(); + } + }); +} + +export async function spawnSandboxedShell(options: SandboxSpawnOptions): Promise<{ child?: ChildProcessWithoutNullStreams; sandbox: SandboxExecutionInfo; error?: string }> { + const planned = await planSandboxInvocation({ + config: options.config, + workspace: options.workspace, + command: options.command, + args: options.args, + shell: options.shell ?? true, + cwd: options.cwd, + env: options.env ?? process.env, + originalCommand: options.originalCommand, + rewrittenCommand: options.rewrittenCommand, + capabilities: options.capabilities, + }); + if (!planned.ok) { + return { sandbox: planned.info, error: planned.stderr }; + } + const child = spawn(planned.invocation.command, planned.invocation.args, { + cwd: options.cwd, + env: planned.invocation.env, + shell: planned.invocation.shell, + detached: process.platform !== "win32", + }); + return { child, sandbox: planned.invocation.info }; +} + +export { sandboxBlockedJson }; + +export function runtimeSandboxInfo(info: SandboxExecutionInfo, code: number | null, stderr: string): SandboxExecutionInfo { + if (code === 0 || info.mode === "off" || info.blocked) { + return info; + } + const policyRule = runtimeSandboxPolicyRule(info, stderr); + if (policyRule) { + return { + ...info, + blocked: true, + block_stage: "runtime", + reason: runtimeSandboxReason(policyRule, stderr), + policy_rule: policyRule, + suggested_action: suggestedActionForPolicyRule(policyRule), + }; + } + return info; +} + +function runtimeSandboxPolicyRule(info: SandboxExecutionInfo, stderr: string): string | undefined { + if (sandboxDenialSignal(stderr)) { + return classifySandboxDenial(info, stderr); + } + if (!stderr.trim() && networkCommandLikelyDeniedByPolicy(info)) { + return "network_restricted"; + } + return undefined; +} + +function runtimeSandboxReason(policyRule: string, stderr: string): string { + const lastLine = stderr.trim().split(/\r?\n/).slice(-1)[0]; + if (lastLine) { + return lastLine; + } + if (policyRule === "network_restricted") { + return "Network is restricted inside the sandbox."; + } + return "Command was blocked by the OS sandbox."; +} + +function sandboxDenialSignal(stderr: string): boolean { + return /operation not permitted|permission denied|read-only file system|sandbox-exec|bwrap|bubblewrap|network is unreachable|could not resolve host|\bEPERM\b|\bEACCES\b|\bENETUNREACH\b|\bEHOSTUNREACH\b/i.test( + stderr, + ); +} + +function classifySandboxDenial(info: SandboxExecutionInfo, stderr: string): string { + if (networkSandboxDenied(info, stderr)) { + return "network_restricted"; + } + const text = `${info.command}\n${info.rewritten_command ?? ""}\n${stderr}`; + if (/\.git(?:\/|['"`:\s]|$)/.test(text) || (/\bgit\b/.test(text) && !info.capabilities?.includes("git_metadata_write"))) { + return "git_metadata_requires_capability"; + } + if (protectedAgentMetadataDenied(text)) { + return "protected_metadata_write"; + } + const blockedPath = firstAbsolutePath(stderr); + if (blockedPath) { + if (blockedPath.startsWith("/dev/")) { + return "platform_baseline_denied"; + } + if (!pathInside(blockedPath, info.workspace_root)) { + return "outside_workspace_write"; + } + } + return "sandbox_runtime_denied"; +} + +function networkSandboxDenied(info: SandboxExecutionInfo, stderr: string): boolean { + if (/network|resolve host|ENETUNREACH|EHOSTUNREACH|connect .*E(?:PERM|ACCES)|E(?:PERM|ACCES).*connect/i.test(stderr)) { + return true; + } + if (info.network !== "restricted" || !/\bE(?:PERM|ACCES)\b/i.test(stderr)) { + return false; + } + const command = `${info.rewritten_command ?? ""}\n${info.command}`.toLowerCase(); + return /\b(curl|wget|ssh|scp|rsync|nc|netcat|telnet|ping)\b|net\.connect|fetch\(|https?:\/\//.test(command); +} + +function networkCommandLikelyDeniedByPolicy(info: SandboxExecutionInfo): boolean { + return info.backend !== "none" && info.network === "restricted" && networkCommandIntent(info); +} + +function networkCommandIntent(info: SandboxExecutionInfo): boolean { + const command = `${info.rewritten_command ?? ""}\n${info.command}`.toLowerCase(); + return /\b(curl|wget)\b\s+\S+|\b(ssh|scp|rsync|nc|netcat|telnet|ping)\b\s+[A-Za-z0-9_.:-]+|net\.connect\s*\(|fetch\s*\(|https?:\/\//.test(command); +} + +function protectedAgentMetadataDenied(text: string): boolean { + if (/\.(?:codex|claude|agents)(?:\/|['"`:\s]|$)/.test(text)) { + return true; + } + const matches = Array.from(text.matchAll(/(?:^|[\/\s'"`:])(\.inferoa(?:\/[^\s'"`:;]*)?)(?=$|[\s'"`:;])/g), (match) => match[1]).filter( + (match): match is string => Boolean(match), + ); + if (!matches) { + return false; + } + return matches.some((match) => { + const relative = match.replace(/^\.inferoa\/?/, ""); + if (!relative) { + return true; + } + return !/^(?:tmp|exports|artifacts|evidence|cache|sandbox)(?:\/|$)/.test(relative); + }); +} + +function suggestedActionForPolicyRule(policyRule: string): string { + switch (policyRule) { + case "outside_workspace_write": + return "Write inside the workspace, use tmp, or add a trusted sandbox.extra_writable_roots entry."; + case "protected_metadata_write": + return "Use agent artifact paths such as .inferoa/exports or avoid modifying protected agent metadata."; + case "git_metadata_requires_capability": + return "Use a Git tool or a non-dangerous Git metadata command; destructive Git operations still require separate approval."; + case "network_restricted": + return "Run /sandbox network on only if this command should reach the network."; + case "platform_baseline_denied": + return "This looks like a missing platform baseline allowance; report the command and keep sandbox enabled."; + default: + return "Inspect /sandbox status, request a narrower capability, or run /sandbox off if you trust this command."; + } +} + +function firstAbsolutePath(text: string): string | undefined { + const match = /(?:^|[\s'"`])((?:\/[A-Za-z0-9._@%+=:, -]+)+)/.exec(text); + return match?.[1]?.trim(); +} + +function pathInside(candidate: string, root: string): boolean { + const relative = candidate.startsWith("/") ? candidate : ""; + if (!relative) { + return false; + } + const normalized = relative.replace(/\/+$/, ""); + const base = root.replace(/\/+$/, ""); + return normalized === base || normalized.startsWith(`${base}/`); +} + +function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void { + const pid = child.pid; + if (pid && process.platform !== "win32") { + try { + process.kill(-pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") { + child.kill(signal); + } + return; + } + } + child.kill(signal); +} diff --git a/src/sandbox/types.ts b/src/sandbox/types.ts new file mode 100644 index 0000000..7d14e8b --- /dev/null +++ b/src/sandbox/types.ts @@ -0,0 +1,76 @@ +import type { JsonObject, SandboxBackend, SandboxMode, SandboxNetworkMode } from "../types.js"; + +export type SandboxCapability = "git_metadata_write"; +export type SandboxBackendId = Exclude; +export type SandboxBlockStage = "preflight" | "backend_setup" | "runtime"; + +export interface SandboxExecutionInfo { + backend: SandboxBackendId; + mode: SandboxMode; + network: SandboxNetworkMode; + workspace_root: string; + cwd: string; + command: string; + rewritten_command?: string; + blocked: boolean; + block_stage?: SandboxBlockStage; + reason?: string; + policy_rule?: string; + suggested_action?: string; + suspected_subcommand?: string; + capabilities?: SandboxCapability[]; +} + +export interface SandboxPolicy { + mode: SandboxMode; + backend: SandboxBackendId; + network: SandboxNetworkMode; + workspaceRoot: string; + cwd: string; + writableRoots: string[]; + readOnlyRoots: string[]; + protectedCreatePaths: string[]; + protectedWritePaths: string[]; + agentWritableRoots: string[]; + envPassthrough: string[]; + failIfUnavailable: boolean; + capabilities: SandboxCapability[]; +} + +export interface SandboxAvailability { + available: boolean; + reason?: string; + executable?: string; +} + +export interface SandboxInvocation { + command: string; + args: string[]; + shell: boolean; + env: NodeJS.ProcessEnv; + info: SandboxExecutionInfo; +} + +export interface SandboxBackendAdapter { + id: SandboxBackendId; + available(): Promise; + build(policy: SandboxPolicy, request: SandboxBuildRequest): Promise; + explainFailure(reason: string): string; +} + +export interface SandboxBuildRequest { + command: string; + args?: string[]; + shell: boolean; + env: NodeJS.ProcessEnv; + originalCommand?: string; + rewrittenCommand?: string; +} + +export function sandboxInfoToJson(info: SandboxExecutionInfo): JsonObject { + return Object.fromEntries(Object.entries(info).filter(([, value]) => value !== undefined)) as JsonObject; +} + +export function blockedSandboxInfoToJson(info: SandboxExecutionInfo | undefined): JsonObject | undefined { + return info?.blocked ? sandboxInfoToJson(info) : undefined; +} diff --git a/src/tools/autoresearch-tools.ts b/src/tools/autoresearch-tools.ts index 3864616..580ce0d 100644 --- a/src/tools/autoresearch-tools.ts +++ b/src/tools/autoresearch-tools.ts @@ -1,9 +1,10 @@ -import { spawn } from "node:child_process"; import path from "node:path"; import { access } from "node:fs/promises"; import type { JsonObject, ToolResult } from "../types.js"; import { fail, ok, truncateText } from "../util/limit.js"; import type { ToolExecutionContext } from "./context.js"; +import { spawnSandboxedShell } from "../sandbox/runner.js"; +import { blockedSandboxInfoToJson } from "../sandbox/types.js"; import { createExperiment, type HarnessValidation, @@ -33,6 +34,7 @@ interface HarnessRunResult { outputTruncated: boolean; parsedMetrics: Record; asi: JsonObject; + sandbox?: JsonObject; } export async function initExperiment(args: JsonObject, context: ToolExecutionContext): Promise { @@ -106,7 +108,7 @@ export async function runExperiment(args: JsonObject, context: ToolExecutionCont return fail("autoresearch_timeout_invalid", error instanceof Error ? error.message : String(error), autoresearchFailureData(state)); } const started = Date.now(); - const result = await runHarness(context.workspace.root, timeoutMs); + const result = await runHarness(context, timeoutMs); const durationMs = Date.now() - started; const resource = context.store.putResource(context.session_id, "autoresearch.run.output", result.output, { command: HARNESS_COMMAND, @@ -114,6 +116,7 @@ export async function runExperiment(args: JsonObject, context: ToolExecutionCont duration_ms: durationMs, timed_out: result.timedOut, output_truncated: result.outputTruncated, + sandbox: result.sandbox, }); const parsedMetrics = result.parsedMetrics; const parsedPrimary = parsedMetrics[state.experiment.primary_metric] ?? null; @@ -127,6 +130,7 @@ export async function runExperiment(args: JsonObject, context: ToolExecutionCont parsed_metrics: parsedMetrics, parsed_primary: parsedPrimary, asi: result.asi, + sandbox: result.sandbox, completed_at: new Date().toISOString(), }); const next = writeAutoresearchState( @@ -146,6 +150,7 @@ export async function runExperiment(args: JsonObject, context: ToolExecutionCont output_resource_uri: resource.uri, parsed_metrics: parsedMetrics, parsed_primary: parsedPrimary, + sandbox: result.sandbox, output_preview: preview, autoresearch: next as unknown as JsonObject, progress: summarizeAutoresearchProgress(nextExperiment) as unknown as JsonObject, @@ -154,7 +159,7 @@ export async function runExperiment(args: JsonObject, context: ToolExecutionCont async function validateHarness(context: ToolExecutionContext, primaryMetric: string): Promise { const started = Date.now(); - const result = await runHarness(context.workspace.root, HARNESS_VALIDATION_TIMEOUT_MS); + const result = await runHarness(context, HARNESS_VALIDATION_TIMEOUT_MS); const durationMs = Date.now() - started; const parsedMetrics = result.parsedMetrics; const parsedPrimary = parsedMetrics[primaryMetric] ?? null; @@ -165,6 +170,7 @@ async function validateHarness(context: ToolExecutionContext, primaryMetric: str primary_metric: primaryMetric, timed_out: result.timedOut, output_truncated: result.outputTruncated, + sandbox: result.sandbox, }); const ok = result.exitCode === 0; const metricMessage = parsedPrimary === null ? `missing METRIC ${primaryMetric}=value` : `${primaryMetric}=${parsedPrimary}`; @@ -178,6 +184,7 @@ async function validateHarness(context: ToolExecutionContext, primaryMetric: str output_resource_uri: resource.uri, timed_out: result.timedOut, output_truncated: result.outputTruncated, + sandbox: result.sandbox, message: result.timedOut ? `harness timed out; ${metricMessage}` : ok ? `validated ${metricMessage}` : `harness exited ${result.exitCode}; ${metricMessage}`, validated_at: new Date().toISOString(), }; @@ -256,9 +263,24 @@ function ensureAutoresearchEnabled(context: ToolExecutionContext): AutoresearchS return setAutoresearchMode(context.store, context.session_id, { mode: "on", goal: state.goal }, context.run_id); } -function runHarness(cwd: string, timeoutMs: number): Promise { +async function runHarness(context: ToolExecutionContext, timeoutMs: number): Promise { + const spawned = await spawnSandboxedShell({ + config: context.config, + workspace: context.workspace, + command: "bash", + args: [HARNESS], + shell: false, + cwd: context.workspace.root, + env: process.env, + originalCommand: HARNESS_COMMAND, + }); + const sandbox = blockedSandboxInfoToJson(spawned.sandbox); + const child = spawned.child; + if (!child) { + const output = spawned.error ?? "sandbox blocked autoresearch harness"; + return { exitCode: 126, output, timedOut: false, outputTruncated: false, parsedMetrics: {}, asi: {}, sandbox }; + } return new Promise((resolve) => { - const child = spawn("bash", [HARNESS], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }); let output = ""; let outputBytes = 0; let outputTruncated = false; @@ -336,7 +358,7 @@ function runHarness(cwd: string, timeoutMs: number): Promise { clearTimeout(hardKillTimer); } flushHarnessParser(); - resolve({ exitCode, output, timedOut, outputTruncated, parsedMetrics, asi }); + resolve({ exitCode, output, timedOut, outputTruncated, parsedMetrics, asi, sandbox }); }; const timeout = setTimeout(() => { timedOut = true; diff --git a/src/tools/code-intelligence.ts b/src/tools/code-intelligence.ts index 8b7aa4b..74d20b6 100644 --- a/src/tools/code-intelligence.ts +++ b/src/tools/code-intelligence.ts @@ -1,13 +1,14 @@ import { promises as fs } from "node:fs"; -import { spawn } from "node:child_process"; import path from "node:path"; import ts from "typescript"; import type { JsonObject, ToolResult } from "../types.js"; -import { resolveInside, runSmallCommand, toPosixPath } from "../util/fs.js"; +import { resolveInside, toPosixPath } from "../util/fs.js"; import { fail, ok } from "../util/limit.js"; import type { ToolExecutionContext } from "./context.js"; import { editFile, simpleUnifiedDiff } from "./workspace-tools.js"; import { decodeEscapedTextArgument } from "./text-args.js"; +import { runSandboxedProcess } from "../sandbox/runner.js"; +import { blockedSandboxInfoToJson } from "../sandbox/types.js"; interface LanguageSpec { id: string; @@ -55,7 +56,7 @@ export async function lspTool(args: JsonObject, context: ToolExecutionContext): if (action === "status") { const specs = await Promise.all( LSP_REGISTRY.map(async (spec) => { - const availableCommand = await firstAvailable(spec.commands); + const availableCommand = await firstAvailable(spec.commands, context); return { id: spec.id, extensions: spec.extensions, @@ -76,7 +77,7 @@ export async function lspTool(args: JsonObject, context: ToolExecutionContext): } const file = resolveInside(context.workspace.root, rel); if (action === "diagnostics") { - return await diagnostics(file, rel); + return await diagnostics(file, rel, context); } if (action === "symbols") { return await symbols(file, rel); @@ -125,7 +126,7 @@ export async function astEdit(args: JsonObject, context: ToolExecutionContext): const operation = String(args.operation); const rawContent = typeof args.content === "string" ? args.content : ""; const content = decodeEscapedTextArgument(rawContent); - const beforeOk = language.startsWith("python") ? await validatePython(text) : validateTypeScript(text, languageForFile(file)).ok; + const beforeOk = language.startsWith("python") ? await validatePython(text, context) : validateTypeScript(text, languageForFile(file)).ok; if (!beforeOk) { return fail("parse_failed_before", `File did not parse before AST edit: ${rel}`); } @@ -148,7 +149,7 @@ export async function astEdit(args: JsonObject, context: ToolExecutionContext): } else { return fail("unsupported_ast_operation", `Unsupported operation: ${operation}`); } - const afterOk = language.startsWith("python") ? await validatePython(updated) : validateTypeScript(updated, languageForFile(file)).ok; + const afterOk = language.startsWith("python") ? await validatePython(updated, context) : validateTypeScript(updated, languageForFile(file)).ok; if (!afterOk) { return fail("parse_failed_after", `AST edit would make file invalid: ${rel}`); } @@ -281,12 +282,21 @@ function selectorNameMatches(name: string | undefined, value: string | undefined return name === value; } -async function diagnostics(file: string, rel: string): Promise { +async function diagnostics(file: string, rel: string, context: ToolExecutionContext): Promise { if (file.endsWith(".py")) { - const result = await runSmallCommand("python3", ["-m", "py_compile", file], path.dirname(file), 20_000); + const result = await runSandboxedProcess({ + config: context.config, + workspace: context.workspace, + command: "python3", + args: ["-m", "py_compile", file], + cwd: path.dirname(file), + env: process.env, + timeoutMs: 20_000, + }); return ok(`Python diagnostics for ${rel}`, { diagnostics: result.code === 0 ? [] : [{ severity: "error", message: result.stderr || result.stdout }], code: result.code, + sandbox: blockedSandboxInfoToJson(result.sandbox), }); } if (/\.[cm]?[jt]sx?$/.test(file)) { @@ -402,13 +412,18 @@ function validateTypeScript(text: string, scriptKind: ts.ScriptKind): { ok: bool return { ok: diagnostics.length === 0, diagnostics }; } -async function validatePython(text: string): Promise { - return await new Promise((resolve) => { - const child = spawn("python3", ["-c", "import ast,sys; ast.parse(sys.stdin.read())"]); - child.on("close", (code) => resolve(code === 0)); - child.on("error", () => resolve(false)); - child.stdin.end(text); +async function validatePython(text: string, context: ToolExecutionContext): Promise { + const result = await runSandboxedProcess({ + config: context.config, + workspace: context.workspace, + command: "python3", + args: ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], + cwd: context.workspace.root, + env: process.env, + timeoutMs: 20_000, + stdin: text, }); + return result.code === 0; } function languageForFile(file: string): ts.ScriptKind { @@ -418,9 +433,17 @@ function languageForFile(file: string): ts.ScriptKind { return ts.ScriptKind.TS; } -async function firstAvailable(commands: string[]): Promise { +async function firstAvailable(commands: string[], context: ToolExecutionContext): Promise { for (const command of commands) { - const result = await runSmallCommand("command", ["-v", command], process.cwd(), 1000); + const result = await runSandboxedProcess({ + config: context.config, + workspace: context.workspace, + command: `command -v ${shellQuote(command)}`, + shell: true, + cwd: context.workspace.root, + env: process.env, + timeoutMs: 1000, + }); if (result.code === 0) { return result.stdout.trim(); } @@ -431,3 +454,10 @@ async function firstAvailable(commands: string[]): Promise { function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/src/tools/process-tools.ts b/src/tools/process-tools.ts index b7fdd3f..96d2f96 100644 --- a/src/tools/process-tools.ts +++ b/src/tools/process-tools.ts @@ -1,10 +1,12 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { JsonObject, ToolResult } from "../types.js"; import { resolveInside } from "../util/fs.js"; import { fail, ok, truncateText } from "../util/limit.js"; import { randomId } from "../util/hash.js"; import type { ToolExecutionContext } from "./context.js"; import { runRtkAwareShellCommand } from "../rtk/command.js"; +import { sandboxBlockedJson, spawnSandboxedShell } from "../sandbox/runner.js"; +import { blockedSandboxInfoToJson, sandboxInfoToJson } from "../sandbox/types.js"; interface LiveProcess { child: ChildProcessWithoutNullStreams; @@ -27,12 +29,20 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext }; if (args.background) { const processId = randomId("p"); - const child = spawn(command, { + const spawned = await spawnSandboxedShell({ + config: context.config, + workspace: context.workspace, + command, cwd, env, shell: true, - detached: process.platform !== "win32", }); + if (!spawned.child) { + return fail("sandbox_blocked", spawned.error ?? "Sandbox blocked background command", { + sandbox: sandboxBlockedJson(spawned.sandbox), + }); + } + const child = spawned.child; context.store.upsertProcess({ session_id: context.session_id, process_id: processId, @@ -45,7 +55,7 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext session_id: context.session_id, run_id: context.run_id, type: "process.started", - data: { process_id: processId, pid: child.pid, command, cwd }, + data: { process_id: processId, pid: child.pid, command, cwd, sandbox: sandboxInfoToJson(spawned.sandbox) }, }); liveProcesses.set(key(context.session_id, processId), { child, session_id: context.session_id, process_id: processId }); child.stdout.on("data", (chunk) => { @@ -74,7 +84,7 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext }); liveProcesses.delete(key(context.session_id, processId)); }); - return ok(`Started background process ${processId}`, { process_id: processId, pid: child.pid ?? null, command, cwd }); + return ok(`Started background process ${processId}`, { process_id: processId, pid: child.pid ?? null, command, cwd, sandbox: blockedSandboxInfoToJson(spawned.sandbox) }); } const timeoutMs = typeof args.timeout_ms === "number" ? Math.max(100, Math.min(args.timeout_ms, 600_000)) : 120_000; @@ -87,6 +97,7 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext tool_name: context.tool_name ?? "run_command", command, cwd, + workspace: context.workspace, env, timeout_ms: timeoutMs, }); @@ -111,6 +122,7 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext cwd, code: result.code, timed_out: result.timed_out, + sandbox: blockedSandboxInfoToJson(result.sandbox), resource_uri: resource, }, }); @@ -123,9 +135,13 @@ export async function runCommand(args: JsonObject, context: ToolExecutionContext code: result.code, timed_out: result.timed_out, output: truncated.text, + sandbox: blockedSandboxInfoToJson(result.sandbox), }, resource_uri: resource, - error: result.code === 0 && !result.timed_out ? undefined : { code: result.timed_out ? "command_timeout" : "command_failed", message: result.stderr || result.stdout }, + error: + result.code === 0 && !result.timed_out + ? undefined + : { code: result.timed_out ? "command_timeout" : result.sandbox?.blocked ? "sandbox_blocked" : "command_failed", message: result.stderr || result.stdout }, }; } diff --git a/src/tools/workspace-tools.ts b/src/tools/workspace-tools.ts index 0612127..dadb66b 100644 --- a/src/tools/workspace-tools.ts +++ b/src/tools/workspace-tools.ts @@ -1,6 +1,5 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { spawn } from "node:child_process"; import type { ResourceRecord } from "../session/store.js"; import type { JsonObject, ToolResult } from "../types.js"; import { resolveInside, resolveReadablePath, resolveWritablePath, runSmallCommand, toPosixPath } from "../util/fs.js"; @@ -10,6 +9,8 @@ import { decodeEscapedTextArgument, textArgumentCandidates } from "./text-args.j import { workspaceExternalPathsAllowed } from "./permissions.js"; import { runRtkAwareShellCommand, type RtkShellCommandResult } from "../rtk/command.js"; import { extensionForContentType, listResourceSummaries, mediaFromResource, resolveResourceReference, resourceRecordSummary } from "./resource-resolver.js"; +import { runSandboxedProcess } from "../sandbox/runner.js"; +import { blockedSandboxInfoToJson, type SandboxExecutionInfo } from "../sandbox/types.js"; export async function listDir(args: JsonObject, context: ToolExecutionContext): Promise { try { @@ -190,7 +191,7 @@ export async function fileSearch(args: JsonObject, context: ToolExecutionContext rgArgs.push("--glob", options.glob); } rgArgs.push("--", query, cwd); - const rg = await runSmallCommand("rg", rgArgs, context.workspace.root, 15_000); + const rg = await runSmallCommand("rg", rgArgs, context.workspace.root, 15_000, { config: context.config, workspace: context.workspace }); if (rg.code === 0 || rg.stdout.trim()) { const lines = rg.stdout.split(/\r?\n/).filter(Boolean).slice(0, limit); const matches = lines.map(parseRgLine).filter(Boolean); @@ -207,7 +208,7 @@ export async function fileSearch(args: JsonObject, context: ToolExecutionContext if (rg.code !== 127 && rg.stderr.trim()) { return fail("file_search_failed", rg.stderr.trim()); } - const grep = await grepSearch(cwd, context.workspace.root, query, options, limit); + const grep = await grepSearch(cwd, context.workspace.root, query, options, limit, context); if (grep.ok) { return ok(`Found ${grep.matches.length} matches for ${query}`, { query, matches: grep.matches }); } @@ -288,27 +289,27 @@ export async function editFile(args: JsonObject, context: ToolExecutionContext): export async function applyPatchTool(args: JsonObject, context: ToolExecutionContext): Promise { const patch = String(args.patch); - return await new Promise((resolve) => { - const child = spawn("git", ["apply", "--whitespace=nowarn"], { - cwd: context.workspace.root, - stdio: ["pipe", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += String(chunk); - }); - child.stderr.on("data", (chunk) => { - stderr += String(chunk); - }); - child.on("close", (code) => { - if (code === 0) { - resolve(ok("Patch applied", { stdout, diff: patch })); - } else { - resolve(fail("patch_failed", stderr || `git apply exited ${code}`, { stdout, stderr, diff: patch })); - } - }); - child.stdin.end(patch); + const result = await runSandboxedProcess({ + config: context.config, + workspace: context.workspace, + command: "git", + args: ["apply", "--whitespace=nowarn"], + shell: false, + cwd: context.workspace.root, + env: process.env, + timeoutMs: 120_000, + stdin: patch, + capabilities: ["git_metadata_write"], + }); + const sandbox = blockedSandboxInfoToJson(result.sandbox); + if (result.code === 0) { + return ok("Patch applied", { stdout: result.stdout, diff: patch, sandbox }); + } + return fail(result.sandbox.blocked ? "sandbox_blocked" : "patch_failed", result.stderr || `git apply exited ${result.code}`, { + stdout: result.stdout, + stderr: result.stderr, + diff: patch, + sandbox, }); } @@ -323,6 +324,7 @@ export async function gitStatus(args: JsonObject, context: ToolExecutionContext) tool_name: context.tool_name ?? "git_status", command: "git status --short --branch", cwd, + workspace: context.workspace, timeout_ms: 10_000, }); return commandResult("git_status", result, context, "git.status"); @@ -341,6 +343,7 @@ export async function gitDiff(args: JsonObject, context: ToolExecutionContext): tool_name: context.tool_name ?? "git_diff", command, cwd, + workspace: context.workspace, timeout_ms: 10_000, }); return commandResult("git_diff", result, context, "git.diff"); @@ -359,6 +362,7 @@ export async function gitShow(args: JsonObject, context: ToolExecutionContext): tool_name: context.tool_name ?? "git_show", command, cwd, + workspace: context.workspace, timeout_ms: 10_000, }); return commandResult("git_show", result, context, "git.show"); @@ -397,7 +401,7 @@ export async function sessionNote(args: JsonObject, context: ToolExecutionContex async function commandResult( name: string, - result: { code: number | null; stdout: string; stderr: string; rtk?: RtkShellCommandResult["rtk"] }, + result: { code: number | null; stdout: string; stderr: string; rtk?: RtkShellCommandResult["rtk"]; sandbox?: SandboxExecutionInfo }, context: ToolExecutionContext, kind: string, ): Promise { @@ -410,9 +414,9 @@ async function commandResult( return { ok: result.code === 0, summary: `${name} exited ${result.code}`, - data: { code: result.code, output: truncated.text }, + data: { code: result.code, output: truncated.text, sandbox: blockedSandboxInfoToJson(result.sandbox) }, resource_uri: resource, - error: result.code === 0 ? undefined : { code: `${name}_failed`, message: result.stderr || result.stdout }, + error: result.code === 0 ? undefined : { code: result.sandbox?.blocked ? "sandbox_blocked" : `${name}_failed`, message: result.stderr || result.stdout }, }; } @@ -483,6 +487,7 @@ async function grepSearch( query: string, options: SearchOptions, limit: number, + context: ToolExecutionContext, ): Promise<{ ok: true; matches: JsonObject[] } | { ok: false; error?: string }> { const grepArgs = ["-R", "-n", "-I", "--exclude-dir=.git", "--exclude-dir=node_modules", "--exclude-dir=dist"]; grepArgs.push(options.regex ? "-E" : "-F"); @@ -493,7 +498,7 @@ async function grepSearch( grepArgs.push("--include", options.glob); } grepArgs.push("--", query, "."); - const result = await runSmallCommand("grep", grepArgs, cwd, 15_000); + const result = await runSmallCommand("grep", grepArgs, cwd, 15_000, { config: context.config, workspace: context.workspace }); if (result.code === 0 || result.stdout.trim()) { const matches = result.stdout .split(/\r?\n/) diff --git a/src/tui/app.ts b/src/tui/app.ts index 876ca5c..d5abd1b 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { createInterface, type Interface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; @@ -59,6 +60,7 @@ import type { OmniEndpointName, OmniEndpointConfig, PermissionMode, + SandboxMode, SessionEvent, SessionRecord, VllmAgentConfig, @@ -69,7 +71,7 @@ import { randomId } from "../util/hash.js"; import { isAbortError } from "../util/abort.js"; import type { loadApp } from "../app.js"; import { ansi, bgLine, bg256, center, centerBlock, fg256, frame, padRight, terminalHeight, terminalWidth, truncateToWidth, visibleWidth } from "./ansi.js"; -import { parseSlashCommand, slashCommandWithSubcommands, slashSubcommands, SLASH_COMMANDS, type SlashCommandName } from "./slash.js"; +import { bareSlashCommandWithSubcommands, parseSlashCommand, slashCommandWithSubcommands, slashSubcommands, SLASH_COMMANDS, type SlashCommandName } from "./slash.js"; import { inferoaActivityLabel, renderActivityLine, renderActivityRecordLine } from "./activity.js"; import { cacheTurnKind, formatDuration, renderCacheFooter, renderCacheReportTurn } from "./cache-footer.js"; import { renderCompactEventLine, renderSessionActivityLines } from "./event-view.js"; @@ -83,6 +85,7 @@ import { filterProviderPickerOptions, providerPickerPage } from "./provider-pick import { renderSessionTranscript } from "./session-transcript.js"; import { renderUnknownSlashCommandNotice } from "./slash-notice.js"; import { effectiveWorkspacePermission, setWorkspacePermissionMode } from "../tools/permissions.js"; +import { resolveSandboxPolicy, sandboxModeLabel } from "../sandbox/policy.js"; import { renderToolCards } from "./tool-renderer.js"; import { withConversationGap } from "./transcript-spacing.js"; import { MarkdownStreamRenderer } from "./markdown.js"; @@ -929,8 +932,8 @@ export class TuiApp { cursor = moveComposerCursorEnd(buffer, cursor); render(); } else if (key === "\t") { - const subcommandRoot = slashCommandWithSubcommands(buffer); - if (subcommandRoot && !buffer.trim().includes(" ")) { + const subcommandRoot = bareSlashCommandWithSubcommands(buffer); + if (subcommandRoot) { buffer = `/${subcommandRoot} `; cursor = buffer.length; compactRanges = []; @@ -1221,6 +1224,9 @@ export class TuiApp { case "access": await this.renderAccessView(args); return; + case "sandbox": + await this.renderSandboxView(args); + return; case "skills": await this.renderSkillsView(args); return; @@ -2410,6 +2416,30 @@ export class TuiApp { this.renderPanel("Access", accessStatusLines(this.app.config, this.app.workspace, target)); } + private async renderSandboxView(args: string): Promise { + const action = parseSandboxAction(args); + if (action.kind === "unknown") { + this.renderNotice("Unknown sandbox command. Use /sandbox status, off, read-only, workspace-write, or network on|off."); + return; + } + if (action.kind === "status") { + this.renderPanel("Sandbox", sandboxStatusLines(this.app.config, this.app.workspace)); + return; + } + const nextConfig = structuredClone(this.app.config); + if (action.kind === "mode") { + nextConfig.sandbox.mode = action.mode; + } else { + nextConfig.sandbox.network = action.network; + } + const target = await saveUserConfig(nextConfig); + Object.assign(this.app.config, nextConfig); + if (!this.app.configFiles.includes(target)) { + this.app.configFiles.push(target); + } + this.renderPanel("Sandbox", sandboxStatusLines(this.app.config, this.app.workspace, target)); + } + private async chooseAccessMode(): Promise { const current = effectiveWorkspacePermission(this.app.config, this.app.workspace).mode; const options: SelectOption[] = [ @@ -5771,6 +5801,88 @@ function parseAccessMode(args: string): PermissionMode | undefined { } } +type SandboxAction = + | { kind: "status" } + | { kind: "mode"; mode: SandboxMode } + | { kind: "network"; network: VllmAgentConfig["sandbox"]["network"] } + | { kind: "unknown" }; + +function parseSandboxAction(args: string): SandboxAction { + const value = args.trim().toLowerCase().replaceAll("_", "-"); + if (!value || value === "status" || value === "show") { + return { kind: "status" }; + } + switch (value) { + case "off": + return { kind: "mode", mode: "off" }; + case "read-only": + case "readonly": + return { kind: "mode", mode: "read_only" }; + case "workspace-write": + case "workspace": + return { kind: "mode", mode: "workspace_write" }; + case "network on": + case "network enabled": + case "network enable": + return { kind: "network", network: "enabled" }; + case "network off": + case "network restricted": + case "network disable": + return { kind: "network", network: "restricted" }; + default: + return { kind: "unknown" }; + } +} + +export function sandboxStatusLines(config: VllmAgentConfig, workspace: WorkspaceIdentity, target?: string): string[] { + const sandbox = config.sandbox; + const policy = resolveSandboxPolicy({ config, workspace, cwd: workspace.root }); + const extraWritable = sandbox.extra_writable_roots.length ? sandbox.extra_writable_roots.join(", ") : "none"; + const envPassthrough = sandbox.env_passthrough.length ? sandbox.env_passthrough.join(", ") : "none"; + const network = sandbox.mode === "off" + ? `${sandbox.network === "enabled" ? "enabled" : "restricted"} ${fg256(244, "(applies when sandbox is enabled)")}` + : sandbox.network === "enabled" ? "enabled" : "restricted"; + const agentWritable = policy.agentWritableRoots.length + ? policy.agentWritableRoots.map((root) => compactWorkspacePath(root)).join(", ") + : "none"; + const writableRoots = sandboxWritableRootsForDisplay(config, workspace); + const lines = [ + `${fg256(39, "Mode")} ${sandboxModeLabel(sandbox.mode)}`, + `${fg256(39, "Configured backend")} ${sandbox.backend}`, + `${fg256(39, "Effective backend")} ${policy.backend}`, + `${fg256(39, "Network")} ${network}`, + `${fg256(39, "Workspace")} ${compactWorkspacePath(workspace.root)}`, + `${fg256(39, "Fail closed")} ${sandbox.fail_if_unavailable ? "on" : "off"}${sandbox.mode === "off" ? fg256(244, " (applies when sandbox is enabled)") : ""}`, + `${fg256(39, "Writable roots")} ${writableRoots}`, + `${fg256(39, "Extra writable")} ${extraWritable}`, + `${fg256(39, "Agent writable metadata")} ${agentWritable}`, + `${fg256(39, "Env passthrough")} ${envPassthrough}`, + "", + fg256(244, "Approval prompts remain controlled by /access. Full access does not disable OS sandboxing."), + ]; + if (target) { + lines.push("", `${fg256(39, "Config")} ${target}`); + } + lines.push("", `${fg256(39, "/sandbox off")} disable · ${fg256(39, "/sandbox read-only")} read-only · ${fg256(39, "/sandbox workspace-write")} workspace/effective tmp writes · ${fg256(39, "/sandbox network on|off")} network`); + return lines; +} + +function sandboxWritableRootsForDisplay(config: VllmAgentConfig, workspace: WorkspaceIdentity): string { + if (config.sandbox.mode !== "workspace_write") { + return "none"; + } + const roots = [ + `workspace: ${compactWorkspacePath(path.resolve(workspace.root))}`, + `tmp: ${compactWorkspacePath(path.resolve(os.tmpdir()))}`, + ...config.sandbox.extra_writable_roots.map((root) => `extra: ${compactWorkspacePath(path.resolve(root))}`), + ]; + return uniqueLabels(roots).join(", "); +} + +function uniqueLabels(values: string[]): string[] { + return [...new Set(values)]; +} + function accessStatusLines(config: VllmAgentConfig, workspace: WorkspaceIdentity, target?: string): string[] { const policy = effectiveWorkspacePermission(config, workspace); const lines = [ diff --git a/src/tui/composer.ts b/src/tui/composer.ts index b95efa4..92e8648 100644 --- a/src/tui/composer.ts +++ b/src/tui/composer.ts @@ -102,6 +102,9 @@ export function resolveComposerSubmission(input: ComposerSubmissionInput): Compo if (trimmed === "/" && item) { return { action: "submit", text: item.value }; } + if (input.buffer.startsWith("/") && item && input.selectionTouched) { + return { action: "submit", text: item.value }; + } if (trimmed === "$" && item && input.selectionTouched) { return { action: "submit", text: item.value }; } @@ -543,7 +546,9 @@ function centerLine(text: string, width: number): string { function renderComposerSuggestion(item: ComposerSuggestion, active: boolean, width: number): string { const marker = active ? fg256(75, "›") : " "; - const labelWidth = item.kind === "command" ? 18 : 28; + const preferredLabelWidth = visibleWidth(item.label); + const maxLabelWidth = item.kind === "command" ? Math.max(18, Math.min(32, Math.floor(width * 0.45))) : 28; + const labelWidth = Math.min(Math.max(item.kind === "command" ? 18 : 28, preferredLabelWidth), maxLabelWidth); const label = padRight(truncateToWidth(item.label, labelWidth), labelWidth); const descriptionWidth = Math.max(8, width - labelWidth - 6); const text = `${marker} ${active ? fg256(87, label) : fg256(250, label)} ${fg256(244, truncateToWidth(item.description, descriptionWidth))}`; diff --git a/src/tui/slash.ts b/src/tui/slash.ts index 67e543a..3f6ac9d 100644 --- a/src/tui/slash.ts +++ b/src/tui/slash.ts @@ -6,6 +6,7 @@ export type SlashCommandName = | "model" | "system" | "access" + | "sandbox" | "skills" | "goal" | "plan" @@ -39,6 +40,7 @@ export const SLASH_COMMANDS: SlashCommandSpec[] = [ { name: "model", description: "Open model/provider selector" }, { name: "system", description: "Show model, web search, Omni, and runtime status" }, { name: "access", description: "Change this workspace's file and tool access" }, + { name: "sandbox", description: "Change OS sandbox mode and network boundary" }, { name: "skills", description: "List skills or manage enabled skills" }, { name: "goal", description: "Run /goal to start a long-horizon recursive goal" }, { name: "plan", description: "Start or manage plan mode" }, @@ -80,6 +82,12 @@ export const SLASH_SUBCOMMANDS: SlashSubcommandSpec[] = [ { command: "access", name: "auto", value: "/access auto", description: "Auto-approve routine tools for this workspace" }, { command: "access", name: "ask", value: "/access ask", description: "Ask before risky access in this workspace" }, { command: "access", name: "custom", value: "/access custom", description: "Use custom config rules for this workspace" }, + { command: "sandbox", name: "status", value: "/sandbox status", description: "Show OS sandbox mode" }, + { command: "sandbox", name: "off", value: "/sandbox off", description: "Disable OS sandboxing" }, + { command: "sandbox", name: "read-only", value: "/sandbox read-only", description: "Enable read-only OS sandboxing" }, + { command: "sandbox", name: "workspace-write", value: "/sandbox workspace-write", description: "Allow workspace and tmp writes" }, + { command: "sandbox", name: "network on", value: "/sandbox network on", description: "Allow network inside sandbox" }, + { command: "sandbox", name: "network off", value: "/sandbox network off", description: "Restrict network inside sandbox" }, { command: "context", name: "status", value: "/context", description: "Show context and code intelligence state" }, { command: "context", name: "reindex", value: "/context reindex", description: "Rebuild the context index" }, { command: "tools", name: "list", value: "/tools", description: "Show fixed tool schemas" }, @@ -150,3 +158,16 @@ export function slashCommandWithSubcommands(input: string): SlashCommandName | u } return slashSubcommands(name).length ? name : undefined; } + +export function bareSlashCommandWithSubcommands(input: string): SlashCommandName | undefined { + const value = input.toLowerCase(); + if (!value.startsWith("/") || /\s/.test(value)) { + return undefined; + } + const rawName = value.slice(1); + const name = rawName ? COMMAND_ALIASES.get(rawName) ?? (rawName as SlashCommandName) : undefined; + if (!name || !COMMANDS.has(name)) { + return undefined; + } + return slashSubcommands(name).length ? name : undefined; +} diff --git a/src/types.ts b/src/types.ts index fc12c67..de11a52 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,9 @@ export type JsonObject = { [key: string]: JsonValue }; export type EndpointMode = "direct" | "auto"; export type ProviderKind = "vllm" | "external"; export type PermissionMode = "ask" | "auto_approve" | "full_access" | "custom"; +export type SandboxMode = "off" | "read_only" | "workspace_write"; +export type SandboxBackend = "auto" | "macos_seatbelt" | "linux_bubblewrap" | "none"; +export type SandboxNetworkMode = "restricted" | "enabled"; export interface ModelSetup { mode: EndpointMode; @@ -91,6 +94,14 @@ export interface VllmAgentConfig { custom?: JsonObject; workspaces?: Record; }; + sandbox: { + mode: SandboxMode; + backend: SandboxBackend; + network: SandboxNetworkMode; + fail_if_unavailable: boolean; + extra_writable_roots: string[]; + env_passthrough: string[]; + }; context: { compression_threshold: number; context_window: number; diff --git a/src/util/fs.ts b/src/util/fs.ts index 5a5a387..e16475d 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -4,6 +4,9 @@ import os from "node:os"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { singlePathInput } from "./path-input.js"; +import { runSandboxedProcess } from "../sandbox/runner.js"; +import type { SandboxCapability, SandboxExecutionInfo } from "../sandbox/types.js"; +import type { VllmAgentConfig, WorkspaceIdentity } from "../types.js"; export function homeStateDir(): string { return path.join(os.homedir(), ".inferoa"); @@ -48,7 +51,23 @@ export async function runSmallCommand( args: string[], cwd: string, timeoutMs: number, -): Promise<{ code: number | null; stdout: string; stderr: string }> { + options: RunSmallCommandOptions = {}, +): Promise<{ code: number | null; stdout: string; stderr: string; sandbox?: SandboxExecutionInfo }> { + if (options.config && options.workspace) { + const useShell = command === "command"; + const result = await runSandboxedProcess({ + config: options.config, + workspace: options.workspace, + command: useShell ? [command, ...args.map(shellQuote)].join(" ") : command, + args: useShell ? undefined : args, + shell: useShell, + cwd, + env: options.env ?? process.env, + timeoutMs, + capabilities: options.capabilities, + }); + return { code: result.code, stdout: result.stdout, stderr: result.stderr, sandbox: result.sandbox }; + } return await new Promise((resolve) => { const child = spawn(command, args, { cwd, shell: command === "command" }); let stdout = ""; @@ -73,6 +92,13 @@ export async function runSmallCommand( }); } +export interface RunSmallCommandOptions { + config?: VllmAgentConfig; + workspace?: WorkspaceIdentity; + env?: NodeJS.ProcessEnv; + capabilities?: SandboxCapability[]; +} + export function toPosixPath(filePath: string): string { return filePath.split(path.sep).join("/"); } @@ -131,3 +157,10 @@ export function normalizeLocalPathInput(requested: string): string { } return input; } + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts new file mode 100644 index 0000000..248157c --- /dev/null +++ b/test/sandbox.test.ts @@ -0,0 +1,442 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { DEFAULT_CONFIG } from "../src/config/defaults.js"; +import { planSandboxInvocation } from "../src/sandbox/planner.js"; +import { inferCapabilities, resolveSandboxPolicy } from "../src/sandbox/policy.js"; +import { MacosSeatbeltBackend, seatbeltPolicy } from "../src/sandbox/backends/macos-seatbelt.js"; +import { LinuxBubblewrapBackend, bubblewrapArgs, readOnlyMountsForPolicy } from "../src/sandbox/backends/linux-bubblewrap.js"; +import { runSandboxedProcess, runtimeSandboxInfo } from "../src/sandbox/runner.js"; +import type { SandboxExecutionInfo } from "../src/sandbox/types.js"; +import type { VllmAgentConfig, WorkspaceIdentity } from "../src/types.js"; + +function config(): VllmAgentConfig { + return structuredClone(DEFAULT_CONFIG); +} + +function workspace(root = path.join(os.tmpdir(), "inferoa-sandbox-test")): WorkspaceIdentity { + return { id: "w_sandbox", root, alias: "sandbox" }; +} + +test("sandbox defaults are explicit and off", () => { + const sandbox = config().sandbox; + assert.equal(sandbox.mode, "off"); + assert.equal(sandbox.backend, "auto"); + assert.equal(sandbox.network, "restricted"); + assert.equal(sandbox.fail_if_unavailable, true); + assert.deepEqual(sandbox.extra_writable_roots, []); + assert.deepEqual(sandbox.env_passthrough, []); +}); + +test("workspace_write protects control metadata while allowing agent artifact roots", () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-policy"); + const next = config(); + next.sandbox.mode = "workspace_write"; + const base = resolveSandboxPolicy({ config: next, workspace: workspace(root), cwd: root, command: "git status" }); + assert.deepEqual(base.writableRoots.slice(0, 2), [root, os.tmpdir()]); + assert.ok(base.protectedWritePaths.includes(path.join(root, ".git"))); + assert.ok(base.protectedWritePaths.includes(path.join(root, ".inferoa"))); + assert.ok(base.protectedWritePaths.includes(path.join(root, ".inferoa", "config"))); + assert.ok(base.protectedWritePaths.includes(path.join(root, ".codex"))); + assert.ok(base.agentWritableRoots.includes(path.join(root, ".inferoa", "exports"))); + assert.ok(base.agentWritableRoots.includes(path.join(root, ".inferoa", "tmp"))); + + const commit = resolveSandboxPolicy({ config: next, workspace: workspace(root), cwd: root, command: "git commit -m test" }); + assert.equal(commit.capabilities.includes("git_metadata_write"), true); + assert.equal(commit.protectedWritePaths.includes(path.join(root, ".git")), false); + assert.ok(commit.protectedWritePaths.includes(path.join(root, ".inferoa"))); + + const hardReset = resolveSandboxPolicy({ config: next, workspace: workspace(root), cwd: root, command: "git reset --hard HEAD" }); + assert.equal(hardReset.capabilities.includes("git_metadata_write"), false); + assert.ok(hardReset.protectedWritePaths.includes(path.join(root, ".git"))); +}); + +test("backend generators encode write roots, protected metadata, and network restriction", () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-backend"); + const next = config(); + next.sandbox.mode = "workspace_write"; + const policy = resolveSandboxPolicy({ config: next, workspace: workspace(root), cwd: root, command: "echo hi" }); + + const seatbelt = seatbeltPolicy(policy); + assert.match(seatbelt, /\(deny file-write\*/); + assert.match(seatbelt, new RegExp(`\\(require-not \\(subpath "${escapeRegExp(root)}"\\)\\)`)); + assert.match(seatbelt, new RegExp(`\\(deny file-write\\* \\(literal "${escapeRegExp(path.join(root, ".git"))}"\\)\\)`)); + assert.match(seatbelt, new RegExp(`\\(deny file-write\\* \\(subpath "${escapeRegExp(path.join(root, ".git"))}"\\)\\)`)); + assert.match(seatbelt, new RegExp(`\\(deny file-write\\* \\(subpath "${escapeRegExp(path.join(root, ".inferoa"))}"\\)\\)`)); + assert.match(seatbelt, new RegExp(`\\(allow file-write\\* \\(subpath "${escapeRegExp(path.join(root, ".inferoa", "exports"))}"\\)\\)`)); + assert.match(seatbelt, /\(allow file-read\* file-test-existence file-write-data[\s\S]*"\/dev\/null"[\s\S]*"\/dev\/zero"/); + assert.match(seatbelt, /\(allow pseudo-tty\)/); + assert.match(seatbelt, /\(deny network\*\)/); + + const bwrap = bubblewrapArgs(policy, ["/bin/sh", "-c", "echo hi"]); + assert.ok(bwrap.includes("--proc")); + assert.ok(bwrap.includes("--unshare-net")); + assert.deepEqual(bwrap.slice(bwrap.indexOf("--bind"), bwrap.indexOf("--bind") + 3), ["--bind", root, root]); + const gitIndex = bwrap.indexOf(path.join(root, ".git")); + assert.equal(bwrap[gitIndex - 1], "--ro-bind"); + const inferoaIndex = bwrap.indexOf(path.join(root, ".inferoa")); + assert.equal(bwrap[inferoaIndex - 1], "--ro-bind"); + const exportsBindIndex = bwrap.indexOf(path.join(root, ".inferoa", "exports")); + assert.equal(bwrap[exportsBindIndex - 1], "--bind"); + const separator = bwrap.lastIndexOf("--"); + assert.deepEqual(bwrap.slice(separator), ["--", "/bin/sh", "-c", "echo hi"]); + + const fallbackBwrap = bubblewrapArgs(policy, ["/bin/sh", "-c", "echo hi"], undefined, { procMode: "readonly_bind" }); + const procIndex = fallbackBwrap.indexOf("/proc"); + assert.equal(fallbackBwrap[procIndex - 1], "--ro-bind"); + assert.equal(fallbackBwrap[procIndex + 1], "/proc"); +}); + +test("linux protected metadata masks skip missing children already covered by a read-only parent", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "inferoa-sandbox-bwrap-masks-")); + try { + await mkdir(path.join(root, ".inferoa", "exports"), { recursive: true }); + const next = config(); + next.sandbox.mode = "workspace_write"; + const policy = resolveSandboxPolicy({ config: next, workspace: workspace(root), cwd: root, command: "echo hi" }); + + const mounts = await readOnlyMountsForPolicy(policy); + assert.ok(mounts.some((mount) => mount.dest === path.join(root, ".inferoa"))); + assert.equal(mounts.some((mount) => mount.dest === path.join(root, ".inferoa", "config")), false); + assert.ok(mounts.some((mount) => mount.dest === path.join(root, ".git"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("planner keeps env intact when sandbox is off", async () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-off"); + const env = { PATH: process.env.PATH ?? "", SECRET_TOKEN: "visible" }; + const planned = await planSandboxInvocation({ + config: config(), + workspace: workspace(root), + command: "node", + args: ["-v"], + shell: false, + cwd: root, + env, + }); + assert.equal(planned.ok, true); + assert.equal(planned.ok && planned.invocation.info.mode, "off"); + assert.equal(planned.ok && planned.invocation.info.backend, "none"); + assert.equal(planned.ok && planned.invocation.env.SECRET_TOKEN, "visible"); +}); + +test("planner scrubs sensitive env when a sandbox mode is active", async () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-env"); + const next = config(); + next.sandbox.mode = "read_only"; + next.sandbox.backend = "none"; + next.sandbox.env_passthrough = ["SECRET_TOKEN"]; + const planned = await planSandboxInvocation({ + config: next, + workspace: workspace(root), + command: "node", + args: ["-v"], + shell: false, + cwd: root, + env: { + PATH: process.env.PATH ?? "", + NORMAL_VALUE: "kept", + OPENAI_API_KEY: "removed", + SECRET_TOKEN: "kept-by-policy", + }, + }); + assert.equal(planned.ok, true); + assert.equal(planned.ok && planned.invocation.env.NORMAL_VALUE, "kept"); + assert.equal(planned.ok && planned.invocation.env.OPENAI_API_KEY, undefined); + assert.equal(planned.ok && planned.invocation.env.SECRET_TOKEN, "kept-by-policy"); +}); + +test("planner infers sandbox capabilities from original commands after rewrites", async () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-original-command"); + const next = config(); + next.sandbox.mode = "workspace_write"; + next.sandbox.backend = "none"; + const planned = await planSandboxInvocation({ + config: next, + workspace: workspace(root), + command: "node", + args: ["-e", "console.log('rewritten wrapper without git text')"], + shell: false, + cwd: root, + env: { PATH: process.env.PATH ?? "" }, + originalCommand: "git branch inferoa-sandbox-probe && git branch -D inferoa-sandbox-probe", + rewrittenCommand: "node -e \"console.log('rewritten wrapper without git text')\"", + }); + assert.equal(planned.ok, true); + assert.equal(planned.ok && planned.invocation.info.capabilities?.includes("git_metadata_write"), true); + assert.equal(planned.ok && planned.invocation.info.suspected_subcommand, "branch"); +}); + +test("runtime denial classifier does not treat sandbox text alone as a sandbox block", async () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-false-positive"); + const next = config(); + next.sandbox.mode = "workspace_write"; + next.sandbox.backend = "none"; + const result = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "/sandbox status", + shell: true, + cwd: root, + timeoutMs: 5_000, + }); + assert.equal(result.code, 127); + assert.equal(result.sandbox.blocked, false); + assert.equal(result.sandbox.policy_rule, undefined); +}); + +test("runtime denial classifier explains restricted network failures even when stderr is empty", () => { + const root = path.join(os.tmpdir(), "inferoa-sandbox-network-classifier"); + const info: SandboxExecutionInfo = { + backend: "linux_bubblewrap", + mode: "workspace_write", + network: "restricted", + workspace_root: root, + cwd: root, + command: "node -e \"const net=require('node:net'); net.connect({host:'1.1.1.1',port:80})\"", + blocked: false, + }; + + const classified = runtimeSandboxInfo(info, 1, ""); + assert.equal(classified.blocked, true); + assert.equal(classified.policy_rule, "network_restricted"); + assert.equal(classified.reason, "Network is restricted inside the sandbox."); + + const noBackend = runtimeSandboxInfo({ ...info, backend: "none" }, 1, ""); + assert.equal(noBackend.blocked, false); + assert.equal(noBackend.policy_rule, undefined); +}); + +test("git capability inference only upgrades non-dangerous metadata commands", () => { + assert.deepEqual(inferCapabilities("git status --short"), []); + assert.deepEqual(inferCapabilities("git commit -m test"), ["git_metadata_write"]); + assert.deepEqual(inferCapabilities("git -C repo branch feature"), ["git_metadata_write"]); + assert.deepEqual(inferCapabilities("git reset --hard HEAD"), []); + assert.deepEqual(inferCapabilities("git clean -fd"), []); +}); + +test("native OS sandbox probes pass on supported hosts", async (t) => { + if (process.platform !== "darwin" && process.platform !== "linux") { + t.skip("native sandbox probes are only defined for macOS and Linux"); + return; + } + const backend = process.platform === "darwin" ? new MacosSeatbeltBackend() : new LinuxBubblewrapBackend(); + const availability = await backend.available(); + if (!availability.available) { + if (process.platform === "linux") { + const root = await mkdtemp(path.join(os.tmpdir(), "inferoa-sandbox-missing-bwrap-")); + try { + const next = config(); + next.sandbox.mode = "workspace_write"; + next.sandbox.backend = "linux_bubblewrap"; + const result = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "true", + shell: true, + cwd: root, + timeoutMs: 5_000, + }); + assert.equal(result.code, 126); + assert.equal(result.sandbox.blocked, true); + assert.equal(result.sandbox.block_stage, "backend_setup"); + assert.equal(result.sandbox.policy_rule, "sandbox_backend_unavailable"); + } finally { + await rm(root, { recursive: true, force: true }); + } + return; + } + t.skip(availability.reason ?? "native sandbox backend unavailable"); + return; + } + + const root = await mkdtemp(path.join(os.tmpdir(), "inferoa-sandbox-native-")); + const external = path.join(os.homedir(), `.inferoa-sandbox-native-${Date.now()}`); + try { + const repoSetup = await runSandboxedProcess({ + config: config(), + workspace: workspace(root), + command: "git init . && git -c user.name=Inferoa -c user.email=inferoa@example.com commit --allow-empty -m init", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(repoSetup.code, 0, repoSetup.stderr); + + const next = config(); + next.sandbox.mode = "workspace_write"; + next.sandbox.backend = process.platform === "darwin" ? "macos_seatbelt" : "linux_bubblewrap"; + next.sandbox.network = "restricted"; + + const platformPlumbing = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "cat /dev/null && : > /dev/null && f=\"$(mktemp -t inferoa-sandbox.XXXXXX)\" && printf ok > \"$f\" && cat \"$f\" && rm -f \"$f\"", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(platformPlumbing.code, 0, platformPlumbing.stderr); + assert.equal(platformPlumbing.stdout.trim(), "ok"); + assert.equal(platformPlumbing.sandbox.blocked, false); + + const writeWorkspace = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: `printf ok > ${shellQuote(path.join(root, "ok.txt"))}`, + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(writeWorkspace.code, 0, writeWorkspace.stderr); + assert.equal(writeWorkspace.sandbox.blocked, false); + + const nodeWriteWorkspace = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: process.execPath, + args: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(path.join(root, "node-ok.txt"))}, 'ok')`], + shell: false, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(nodeWriteWorkspace.code, 0, nodeWriteWorkspace.stderr); + assert.equal(nodeWriteWorkspace.sandbox.blocked, false); + + const writeInferoaArtifact = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "printf ok > .inferoa/exports/result.txt && printf ok > .inferoa/tmp/work.txt && cat .inferoa/exports/result.txt", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(writeInferoaArtifact.code, 0, writeInferoaArtifact.stderr); + assert.equal(writeInferoaArtifact.stdout.trim(), "ok"); + assert.equal(writeInferoaArtifact.sandbox.blocked, false); + + const writeExternal = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: `printf no > ${shellQuote(external)}`, + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(writeExternal.code, 0); + assert.equal(writeExternal.sandbox.blocked, true); + assert.equal(writeExternal.sandbox.policy_rule, "outside_workspace_write"); + + const writeGit = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: `printf no > ${shellQuote(path.join(root, ".git", "blocked"))}`, + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(writeGit.code, 0); + assert.equal(writeGit.sandbox.blocked, true); + assert.equal(writeGit.sandbox.policy_rule, "git_metadata_requires_capability"); + + const writeInferoaConfig = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "printf no > .inferoa/config.yaml", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(writeInferoaConfig.code, 0); + assert.equal(writeInferoaConfig.sandbox.blocked, true); + assert.equal(writeInferoaConfig.sandbox.policy_rule, "protected_metadata_write"); + + const writeInferoaUnknown = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "printf no > .inferoa/unknown.txt", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(writeInferoaUnknown.code, 0); + assert.equal(writeInferoaUnknown.sandbox.blocked, true); + assert.equal(writeInferoaUnknown.sandbox.policy_rule, "protected_metadata_write"); + + const writeGitWithCapability = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: `printf ok > ${shellQuote(path.join(root, ".git", "allowed"))}`, + shell: true, + cwd: root, + timeoutMs: 8_000, + capabilities: ["git_metadata_write"], + }); + assert.equal(writeGitWithCapability.code, 0, writeGitWithCapability.stderr); + assert.equal(writeGitWithCapability.sandbox.blocked, false); + + const gitBranchWithInferredCapability = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "git branch inferoa-sandbox-probe && git branch -D inferoa-sandbox-probe", + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.equal(gitBranchWithInferredCapability.code, 0, gitBranchWithInferredCapability.stderr); + assert.equal(gitBranchWithInferredCapability.sandbox.blocked, false); + + const gitBranchWithRewrittenMetadata = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: "git branch inferoa-sandbox-probe-rewritten && git branch -D inferoa-sandbox-probe-rewritten", + shell: true, + cwd: root, + timeoutMs: 8_000, + originalCommand: "git branch inferoa-sandbox-probe-rewritten && git branch -D inferoa-sandbox-probe-rewritten", + rewrittenCommand: "node -e \"console.log('rtk wrapper without git text')\"", + }); + assert.equal(gitBranchWithRewrittenMetadata.code, 0, gitBranchWithRewrittenMetadata.stderr); + assert.equal(gitBranchWithRewrittenMetadata.sandbox.blocked, false); + assert.equal(gitBranchWithRewrittenMetadata.sandbox.capabilities?.includes("git_metadata_write"), true); + + const symlinkEscape = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: `ln -s ${shellQuote(external)} sandbox-link-out && printf no > sandbox-link-out`, + shell: true, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(symlinkEscape.code, 0); + assert.equal(symlinkEscape.sandbox.blocked, true); + + const network = await runSandboxedProcess({ + config: next, + workspace: workspace(root), + command: process.execPath, + args: ["-e", "const net=require('node:net'); const s=net.connect({host:'1.1.1.1',port:80,timeout:500}); s.on('connect',()=>process.exit(0)); s.on('error',(e)=>{console.error(e.code||e.message); process.exit(1);}); s.on('timeout',()=>process.exit(2));"], + shell: false, + cwd: root, + timeoutMs: 8_000, + }); + assert.notEqual(network.code, 0); + assert.equal(network.sandbox.blocked, true); + assert.equal(network.sandbox.policy_rule, "network_restricted"); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(external, { force: true }); + } +}); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/test/slash.test.ts b/test/slash.test.ts index 4ecc969..a3c63b2 100644 --- a/test/slash.test.ts +++ b/test/slash.test.ts @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { stripAnsi } from "../src/tui/ansi.js"; -import { parseSlashCommand, slashCommandWithSubcommands, slashSubcommands } from "../src/tui/slash.js"; +import { renderComposerSurface } from "../src/tui/composer.js"; +import { bareSlashCommandWithSubcommands, parseSlashCommand, slashCommandWithSubcommands, slashSubcommands } from "../src/tui/slash.js"; import { renderUnknownSlashCommandNotice } from "../src/tui/slash-notice.js"; test("slash parser uses clear as the fresh-session command", () => { @@ -76,6 +77,11 @@ test("slash parser exposes tokenmaxxing and keeps old savings aliases", () => { assert.equal(access.command?.name, "access"); assert.equal(access.args, "full"); assert.equal(access.error, undefined); + + const sandbox = parseSlashCommand("/sandbox workspace-write"); + assert.equal(sandbox.command?.name, "sandbox"); + assert.equal(sandbox.args, "workspace-write"); + assert.equal(sandbox.error, undefined); }); test("slash parser leaves dragged absolute paths as chat input", () => { @@ -111,6 +117,7 @@ test("slash registry exposes chat subcommands for completion", () => { assert.equal(slashCommandWithSubcommands("/goal"), "goal"); assert.equal(slashCommandWithSubcommands("/plan"), "plan"); assert.equal(slashCommandWithSubcommands("/autoresearch"), "autoresearch"); + assert.equal(slashCommandWithSubcommands("/sandbox"), "sandbox"); assert.equal(slashCommandWithSubcommands("/sessions"), "sessions"); assert.equal(slashCommandWithSubcommands("/clear"), undefined); assert.equal(parseSlashCommand("/jobs").error, "Unrecognized command '/jobs'. Type '/' for commands."); @@ -144,8 +151,28 @@ test("slash registry exposes chat subcommands for completion", () => { slashSubcommands("access").map((item) => item.value), ["/access status", "/access full", "/access auto", "/access ask", "/access custom"], ); + assert.deepEqual( + slashSubcommands("sandbox").map((item) => item.value), + ["/sandbox status", "/sandbox off", "/sandbox read-only", "/sandbox workspace-write", "/sandbox network on", "/sandbox network off"], + ); assert.deepEqual( slashSubcommands("sessions").map((item) => item.value), ["/sessions resume", "/sessions new", "/sessions all"], ); }); + +test("bare slash subcommand expansion does not consume trailing-space subcommand completion", () => { + assert.equal(bareSlashCommandWithSubcommands("/sandbox"), "sandbox"); + assert.equal(bareSlashCommandWithSubcommands("/sandbox "), undefined); + assert.equal(bareSlashCommandWithSubcommands("/sandbox network"), undefined); +}); + +test("composer renders sandbox network subcommands distinctly", () => { + const items = slashSubcommands("sandbox") + .filter((item) => item.value.startsWith("/sandbox network")) + .map((item) => ({ label: item.value, description: item.description, kind: "command" as const })); + const rendered = stripAnsi(renderComposerSurface({ buffer: "/sandbox ", cursor: "/sandbox ".length, items, selected: 0, width: 90 }).lines.join("\n")); + + assert.match(rendered, /\/sandbox network on\s+Allow network inside sandbox/); + assert.match(rendered, /\/sandbox network off\s+Restrict network inside sandbox/); +}); diff --git a/test/tui-composer.test.ts b/test/tui-composer.test.ts index 5034655..0ecb2fc 100644 --- a/test/tui-composer.test.ts +++ b/test/tui-composer.test.ts @@ -255,6 +255,18 @@ test("composer submits slash-looking text when slash validation is disabled", () assert.deepEqual(decision, { action: "submit", text: "/docker" }); }); +test("composer submits selected slash subcommand completions", () => { + const decision = resolveComposerSubmission({ + buffer: "/sandbox ", + compactRanges: [], + items: [{ value: "/sandbox network on" }, { value: "/sandbox network off" }], + selected: 1, + selectionTouched: true, + }); + + assert.deepEqual(decision, { action: "submit", text: "/sandbox network off" }); +}); + function visiblePlainWidth(text: string): number { return [...text].reduce((width, char) => width + (char.codePointAt(0)! > 0xff ? 2 : 1), 0); } diff --git a/test/tui-omni.test.ts b/test/tui-omni.test.ts index 9b94500..2c461dc 100644 --- a/test/tui-omni.test.ts +++ b/test/tui-omni.test.ts @@ -1,5 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; import { DEFAULT_CONFIG } from "../src/config/defaults.js"; import { stripAnsi, visibleWidth } from "../src/tui/ansi.js"; import { @@ -9,6 +11,7 @@ import { endpointStatusLinesForDisplay, normalizeContextWindowInput, PREFIX_CACHE_REPORT_TITLE, + sandboxStatusLines, setupReviewLinesForDisplay, TUI_OMNI_SETUP_CAPABILITIES, webSearchProviderSetupOptions, @@ -118,6 +121,20 @@ test("system status renders Omni capability matrix details", () => { assert.match(lines, /http:\/\/omni\.example\/v1 · image-model/); }); +test("sandbox status shows effective writable roots instead of ambiguous tmp wording", () => { + const config = structuredClone(DEFAULT_CONFIG); + config.sandbox.mode = "workspace_write"; + config.sandbox.extra_writable_roots = ["/opt/inferoa-extra"]; + const workspace = { id: "w_sandbox", root: "/workspace/inferoa", alias: "inferoa" }; + const lines = stripAnsi(sandboxStatusLines(config, workspace).join("\n")); + + assert.match(lines, /Writable roots workspace: \/workspace\/inferoa, tmp: /); + assert.match(lines, new RegExp(`tmp: ${escapeRegExp(path.resolve(os.tmpdir()))}`)); + assert.match(lines, /extra: \/opt\/inferoa-extra/); + assert.doesNotMatch(lines, /workspace\/tmp writes/); + assert.match(lines, /workspace\/effective tmp writes/); +}); + test("TUI setup review uses full-width rows and does not truncate final summary", () => { const config = structuredClone(DEFAULT_CONFIG); config.model_setup.mode = "direct"; @@ -152,6 +169,10 @@ test("TUI setup review uses full-width rows and does not truncate final summary" assert.match(plain, /local vault/); }); +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + test("prefix cache report excludes the warmup turn from aggregate hit rate", () => { const lines = stripAnsi(cacheEvidenceOverview([ { run_id: "run_1", usage: { prompt_tokens: 1000, cached_prompt_tokens: 58 } },