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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/capabilities/sandboxed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ExecResult> {
// 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?.();
}
},
},
};
}
5 changes: 4 additions & 1 deletion src/channels/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/heartbeat/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/idle/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
});
Expand Down
49 changes: 48 additions & 1 deletion src/sandbox/mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */

Expand Down Expand Up @@ -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,
);
}
});
});
69 changes: 66 additions & 3 deletions src/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,32 @@ export interface SandboxedCommand {
export async function wrapForSandbox(
spec: SandboxSpec,
shellCommand: string,
): Promise<SandboxedCommand> {
// 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<SandboxedCommand> {
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<SandboxedCommand> {
if (!modeIsBounded(spec.mode)) {
return { command: "/bin/bash", args: ["-lc", shellCommand] };
return { command: program[0]!, args: program.slice(1) };
}

if (process.platform === "darwin") {
Expand All @@ -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);
Expand All @@ -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(
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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}`;
},
Expand Down
3 changes: 3 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down