diff --git a/src/capabilities/sandboxed.ts b/src/capabilities/sandboxed.ts index b00a567..546eaf9 100644 --- a/src/capabilities/sandboxed.ts +++ b/src/capabilities/sandboxed.ts @@ -30,7 +30,7 @@ import path from "node:path"; import fsp from "node:fs/promises"; import { localFs, localShell } from "./local.js"; import type { Capabilities, ExecOptions, ExecResult, FsCapability } from "./types.js"; -import { wrapForSandbox, type SandboxSpec } from "../sandbox/sandbox.js"; +import { wrapForSandbox, wrapArgvForSandbox, type SandboxSpec } from "../sandbox/sandbox.js"; import { modeIsBounded, modeAllowsWrites } from "../sandbox/mode.js"; export interface SandboxedOptions { @@ -147,7 +147,22 @@ export function createSandboxedCapabilities(opts: SandboxedOptions): Capabilitie await wrapped.cleanup?.(); } }, - exec: localShell.exec, + async exec(command: string, args: string[], execOpts: ExecOptions): Promise { + // MED-3: exec used to run raw/unconfined even in bounded modes, so a + // mutating program invoked via argv (or a change to grep's args) would + // silently escape read-only/workspace-write. Route it through the same + // fail-closed confinement as run() so ONE mode governs both shell halves. + const wrapped = await wrapArgvForSandbox( + { ...opts.spec, cwd: execOpts.cwd }, + command, + args, + ); + try { + return await localShell.exec(wrapped.command, wrapped.args, execOpts); + } finally { + await wrapped.cleanup?.(); + } + }, }, }; } diff --git a/src/channels/router.ts b/src/channels/router.ts index 409fc33..902b9c1 100644 --- a/src/channels/router.ts +++ b/src/channels/router.ts @@ -4,6 +4,7 @@ import { providerForModel } from "../providers/registry.js"; import { buildSystemPromptSnapshot, type PromptSnapshot } from "../prompt.js"; import { reflectOnSession } from "../reflect.js"; import { SessionStore } from "../sessions/store.js"; +import { untrustedSurfaceMode } from "../sandbox/sandbox.js"; import type { StoredMessage, ToolDefinition, @@ -135,7 +136,9 @@ export class ChannelRouter { cwd: this.opts.cwd, signal: this.opts.signal, log: () => {}, - sandboxMode: this.opts.sandboxMode, + // Channels are remote-origin/untrusted; default to the confined + // untrusted-surface mode unless the operator pinned one explicitly. + sandboxMode: this.opts.sandboxMode ?? untrustedSurfaceMode(), }, history: ctx.history, userMessage: msg.text, diff --git a/src/heartbeat/runner.ts b/src/heartbeat/runner.ts index a3e6e73..84629b5 100644 --- a/src/heartbeat/runner.ts +++ b/src/heartbeat/runner.ts @@ -19,6 +19,7 @@ import { withFileLock } from "../soul/lock.js"; import { getAutonomyEnabled } from "../autonomy/state.js"; import { autonomousSubset, desireReviewSubset } from "../tools/registry.js"; import { runSubagent } from "../subagent.js"; +import { untrustedSurfaceMode } from "../sandbox/sandbox.js"; import { recordAutonomyRun, type AutonomyKind } from "../autonomy/runs.js"; import { recentAgentRecap } from "../orchestrator/recent-recap.js"; import type { ToolDefinition } from "../types.js"; @@ -202,6 +203,8 @@ async function runHeartbeatInner(opts: { signal: opts.signal, model: opts.model, moodOrigin: `a ${runKind} turn`, + // Unattended self-driven run — confine to the untrusted-surface mode. H2. + sandboxMode: untrustedSurfaceMode(), }); } catch (err) { await recordAutonomyRun({ @@ -306,6 +309,7 @@ export async function runDesireReviewOnce(opts: { model: opts.model, budgetTokens: 100_000, provider: opts.provider, + sandboxMode: untrustedSurfaceMode(), moodOrigin: "a desire-review turn", }); const text = result.text diff --git a/src/idle/runner.ts b/src/idle/runner.ts index 62e409a..97ed6cb 100644 --- a/src/idle/runner.ts +++ b/src/idle/runner.ts @@ -3,6 +3,7 @@ import { lisaHome } from "../paths.js"; import { withFileLock } from "../soul/lock.js"; import { autonomousSubset } from "../tools/registry.js"; import { runSubagent } from "../subagent.js"; +import { untrustedSurfaceMode } from "../sandbox/sandbox.js"; import { recordAutonomyRun, type AutonomyOutcome } from "../autonomy/runs.js"; import { getAutonomyEnabled } from "../autonomy/state.js"; import { readIndex } from "../kb/store.js"; @@ -159,6 +160,9 @@ async function runIdleInner( cwd: opts.cwd, signal: opts.signal, model: opts.model, + // Unattended: confine to the untrusted-surface mode (defense in depth + // atop the tool subset above). H2. + sandboxMode: untrustedSurfaceMode(), budgetTokens: IDLE_BUDGET_TOKENS || undefined, moodOrigin: "an idle turn while the user was away", }); diff --git a/src/sandbox/mode.test.ts b/src/sandbox/mode.test.ts index 92bbf4c..7d12477 100644 --- a/src/sandbox/mode.test.ts +++ b/src/sandbox/mode.test.ts @@ -8,7 +8,12 @@ import { resolveSandboxMode, } from "./mode.js"; import { buildMacosSeatbeltPolicy } from "./macos.js"; -import { wrapForSandbox } from "./sandbox.js"; +import { + wrapForSandbox, + wrapArgvForSandbox, + sandboxEnforceable, + untrustedSurfaceMode, +} from "./sandbox.js"; /** H2 acceptance (docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3). */ @@ -135,3 +140,45 @@ describe("wrapForSandbox — fail closed, never silently unconfined", () => { assert.equal(existsSync(wrapped.args[1]!), false, "policy file removed"); }); }); + +describe("untrusted surfaces + argv confinement (H2 follow-up)", () => { + test("untrusted surfaces never inherit danger-full-access where the sandbox is enforceable", () => { + if (sandboxEnforceable()) { + assert.equal(untrustedSurfaceMode(), "workspace-write"); + } else { + // No OS mechanism to enforce a bounded mode: it can't confine, so it + // falls back to the env default (and warns once) rather than break. + assert.equal(untrustedSurfaceMode(), resolveSandboxMode()); + } + }); + + test("a stricter env pin (read-only) is honoured over the workspace-write cap", () => { + process.env.LISA_SANDBOX_MODE = "read-only"; + assert.equal(untrustedSurfaceMode(), "read-only"); + }); + + test("wrapArgvForSandbox runs the argv directly when unconfined", async () => { + const w = await wrapArgvForSandbox( + { mode: "danger-full-access" as const, allowNetwork: true, cwd: process.cwd() }, + "grep", + ["-n", "needle", "file.txt"], + ); + assert.equal(w.command, "grep"); + assert.deepEqual(w.args, ["-n", "needle", "file.txt"]); + }); + + test("wrapArgvForSandbox confines a bounded mode, or fails closed with no mechanism", async () => { + const spec = { mode: "workspace-write" as const, allowNetwork: false, cwd: process.cwd() }; + if (sandboxEnforceable()) { + const w = await wrapArgvForSandbox(spec, "rm", ["-rf", "x"]); + assert.notEqual(w.command, "rm", "the program runs under the sandbox launcher, not directly"); + assert.ok(w.args.includes("rm"), "the argv is wrapped, not dropped"); + await w.cleanup?.(); + } else { + await assert.rejects( + wrapArgvForSandbox(spec, "rm", ["-rf", "x"]), + SandboxUnavailableError, + ); + } + }); +}); diff --git a/src/sandbox/sandbox.ts b/src/sandbox/sandbox.ts index 6246e5b..16d0190 100644 --- a/src/sandbox/sandbox.ts +++ b/src/sandbox/sandbox.ts @@ -36,9 +36,32 @@ export interface SandboxedCommand { export async function wrapForSandbox( spec: SandboxSpec, shellCommand: string, +): Promise { + // The shell form: run the command string through a login shell. + return wrapProgram(spec, ["/bin/bash", "-lc", shellCommand]); +} + +/** + * The argv form of {@link wrapForSandbox}: confine a `command + args` invocation + * (no shell) under the same mechanism and the same fail-closed rule. This is + * what `ShellCapability.exec` uses so a bounded mode governs BOTH shell halves — + * previously `exec` ran unconfined even in `read-only`/`workspace-write`. + */ +export async function wrapArgvForSandbox( + spec: SandboxSpec, + command: string, + args: string[], +): Promise { + return wrapProgram(spec, [command, ...args]); +} + +/** Shared core: wrap a full argv (`program[0]` = executable) for `spec.mode`. */ +async function wrapProgram( + spec: SandboxSpec, + program: string[], ): Promise { if (!modeIsBounded(spec.mode)) { - return { command: "/bin/bash", args: ["-lc", shellCommand] }; + return { command: program[0]!, args: program.slice(1) }; } if (process.platform === "darwin") { @@ -54,7 +77,7 @@ export async function wrapForSandbox( await fs.writeFile(tmp, policy, "utf8"); return { command: "/usr/bin/sandbox-exec", - args: ["-f", tmp, "/bin/bash", "-lc", shellCommand], + args: ["-f", tmp, ...program], cleanup: async () => { try { await fs.unlink(tmp); @@ -64,7 +87,7 @@ export async function wrapForSandbox( } if (process.platform === "linux" && hasBubblewrap()) { - return { command: "bwrap", args: [...bwrapArgs(spec), "/bin/bash", "-lc", shellCommand] }; + return { command: "bwrap", args: [...bwrapArgs(spec), ...program] }; } throw new SandboxUnavailableError( @@ -77,6 +100,46 @@ export async function wrapForSandbox( ); } +/** True when this host has a mechanism that can actually enforce a bounded mode. */ +export function sandboxEnforceable(): boolean { + return ( + process.platform === "darwin" || + (process.platform === "linux" && hasBubblewrap()) + ); +} + +let warnedUnenforceable = false; +/** + * The mode the unattended / untrusted surfaces (channels, idle, heartbeat, + * feed/mail classification) default to. These process the least-trusted input + * — an inbound DM, a fetched web page — so they should not inherit the local + * user's `danger-full-access`. Where the host can enforce it we cap them at + * `workspace-write` (honouring a stricter env pin); where it cannot, bounded + * modes would fail closed and silently break autonomy, so we keep the env + * default and warn ONCE — a loud "install bwrap" beats a broken heartbeat. + * An operator can force fail-closed everywhere with `LISA_SANDBOX_MODE`. + */ +export function untrustedSurfaceMode(): SandboxMode { + const env = resolveSandboxMode(); + if (!sandboxEnforceable()) { + if (!warnedUnenforceable) { + warnedUnenforceable = true; + console.error( + "[sandbox] untrusted surfaces (channels/idle/heartbeat) run UNCONFINED — no OS " + + "sandbox on this host; install bubblewrap (Linux) or set LISA_SANDBOX_MODE to confine them.", + ); + } + return env; + } + // Enforceable: never looser than workspace-write, but honour a stricter pin. + return env === "read-only" ? "read-only" : "workspace-write"; +} + +/** Test hook — the one-time unenforceable warning. */ +export function _resetUntrustedWarningForTest(): void { + warnedUnenforceable = false; +} + /** * bubblewrap invocation for a bounded mode: a read-only bind of the whole * filesystem, then the writable paths the mode grants layered on top. diff --git a/src/tools/task.ts b/src/tools/task.ts index 13323b1..a601c07 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -55,7 +55,7 @@ export function createTaskTool(deps: { }, required: ["description", "prompt"], }, - async execute(input) { + async execute(input, ctx) { const tools = input.type === "explore" ? deps.readOnlyToolset() : deps.fullToolset(); const system = @@ -67,6 +67,9 @@ export function createTaskTool(deps: { cwd: deps.cwd, signal: deps.signal, model: input.model ?? deps.defaultModel, + // A dispatched subagent inherits the parent turn's confinement — it must + // not be able to escape the sandbox its caller runs under. H2. + sandboxMode: ctx?.sandboxMode, }); return `[subagent: ${input.description} — ${result.toolCallCount} tool calls, ${result.outputTokens} tokens]\n${result.text}`; }, diff --git a/src/web/server.ts b/src/web/server.ts index 220eb29..e6e1569 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3973,6 +3973,9 @@ self.addEventListener('fetch', (event) => { // Abort on server shutdown OR this client disconnecting (Stop). signal: AbortSignal.any([abort.signal, turnAbort.signal]), log: () => {}, + // Pin the turn to the session's mode, frozen at creation (H2), so + // concurrent sessions confine independently of the process env. + sandboxMode: chat.session.header.sandboxMode, }, history: modelContext.history, userMessage: message,