diff --git a/README.md b/README.md index abb5a2fb..6135d1ad 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,7 @@ On Linux, `lisa heartbeat install` prints a cron line for you to add to `crontab | Tool | Purpose | |---|---| | `read` `write` `edit` `apply_patch` | File ops (single + batched) | -| `bash` | Shell (with optional macOS Seatbelt sandbox via `LISA_SANDBOX=1`) | +| `bash` | Shell (confined by the sandbox mode — see below) | | `grep` `ls` | Search + listing | | `task` | Spawn a focused sub-agent in its own context window | | `dispatch_agent` `signal_agent` `dispatch_status` | Launch / stop / track agents she runs (managed + PTY); refuses directories another agent owns | @@ -476,6 +476,20 @@ On Linux, `lisa heartbeat install` prints a cron line for you to add to `crontab | `memory` `memory_search` | Memory CRUD + TF-IDF search across all past sessions | | `kb_search` `kb_read` `kb_list` `kb_links` `kb_add` `kb_write` `kb_ingest` | Personal knowledge base — search + read/list, explore the link graph, add a source, write/maintain a wiki page, ingest a URL (WeChat / Bilibili / YouTube / any article) | | `set_mood` | Switch her visible portrait to one of 114 moods | + +### Sandbox modes + +File and shell tools share one **execution world**, so they can never be bounded to different roots: + +| mode | file reads | file writes | shell | +|---|---|---|---| +| `read-only` | anywhere | refused | no writable path | +| `workspace-write` | anywhere | workspace + temp | workspace + temp | +| `danger-full-access` *(default)* | anywhere | anywhere | unconfined | + +Set with `LISA_SANDBOX_MODE`; `LISA_SANDBOX=1` remains an alias for `workspace-write`. Shell confinement is enforced by the OS — macOS Seatbelt, Linux bubblewrap. **On a platform where the requested mode cannot be enforced, the command is refused (`SANDBOX_UNAVAILABLE`) rather than silently run unconfined** — believing a sandbox is on when it is off is worse than knowing there is none. File confinement is enforced in-process, so it holds everywhere, and it resolves symlinks so a link inside the workspace cannot be written through. + +The mode is fixed when a session is created and recorded in its header: changing the setting never widens what an already-running task may do. | `soul_patch` `soul_journal` `soul_feel` `soul_read` | Her soul-editing tools (hers alone) | | `soul_history` `soul_diff` | Read the git-backed history of her own soul (every change committed with attribution) | | `soul_object` | Architectural objection — flags a constitutional concern; the agent loop forces it to be surfaced in her reply | @@ -551,7 +565,8 @@ LISA_PTY_CLAUDE_CMD=claude # override the `claude` binary path LISA_PTY_CODEX_CMD=codex # override the `codex` binary path # Sandbox -LISA_SANDBOX=1 # opt-in macOS Seatbelt for `bash` +LISA_SANDBOX_MODE=workspace-write # read-only | workspace-write | danger-full-access +LISA_SANDBOX=1 # legacy alias for workspace-write LISA_SANDBOX_NETWORK=0 # block network in sandbox # Web @@ -691,7 +706,8 @@ src/ ├── kb/ ★ personal knowledge base (Karpathy 3-layer: sources + wiki) + kb_* tools + TF-IDF ├── mail/ read-only IMAP / Gmail-OAuth mailbox — classify + daily digest + alerts ├── sessions/ JSONL store + list + resume + paginated read -├── sandbox/ macOS sandbox-exec policy + wrapper +├── capabilities/ fs / shell capability seam — local, sandboxed, in-memory providers +├── sandbox/ sandbox modes + macOS Seatbelt / Linux bwrap policies ├── mcp/ config + stdio client (wraps MCP tools as Lisa tools) ├── plugins/ claude-code-style plugin loader ├── hooks/ PreToolUse / PostToolUse / SessionStart / etc. diff --git a/src/capabilities/index.ts b/src/capabilities/index.ts index 413fbfd8..afc08950 100644 --- a/src/capabilities/index.ts +++ b/src/capabilities/index.ts @@ -1,32 +1,59 @@ /** - * Capability access for tools (H1 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2). + * Capability access for tools (H1/H2 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2–3). * * `ToolContext.caps` is optional so that the dozen existing places that build a - * ToolContext keep compiling and keep their current behaviour. Tools therefore - * never read `ctx.caps` directly — they go through `capsOf`, which supplies the - * local world when a caller has not chosen one. One import per tool, no - * `?? LOCAL` repeated at fourteen call sites where one could be forgotten. + * ToolContext keep compiling. Tools therefore never read `ctx.caps` directly — + * they go through `capsOf`, which supplies a default world when a caller has + * not chosen one. One import per tool, no `?? LOCAL` repeated at fourteen call + * sites where one could be forgotten. * - * SCOPE (H1) — this seam covers the seven primitive fs/shell tools + * That default is where the sandbox actually takes effect for everybody: with + * `danger-full-access` (the default mode) it is the plain local world, exactly + * as before; with any bounded mode it is the sandboxed world, so `write`, + * `edit` and `apply_patch` are confined too rather than only `bash`. + * + * SCOPE — this seam covers the seven primitive fs/shell tools * (read / write / edit / apply_patch / ls / grep / bash). The tools that spawn * in the *workspace* through `src/tools/exec-util.ts` (run_checks, * compare_agents, redeploy, dispatch_agent, and the repo/PR/review helpers) - * still use a raw `spawn` and do NOT pass through `caps.shell` yet. A sandbox - * provider swapped in at `ctx.caps` (H2) therefore bounds the primitives but - * not those — routing the exec-util family through the seam is the follow-up - * that makes "bound the world in one place" literally true. Until then, do not - * assume `capsOf` is the *only* path to the filesystem or a subprocess. + * still use a raw `spawn` and do NOT pass through `caps.shell` yet — a sandbox + * provider bounds the primitives but not those. Routing the exec-util family + * through the seam is the follow-up that makes "bound the world in one place" + * literally true; until then, do not assume `capsOf` is the *only* path to the + * filesystem or a subprocess. */ import type { ToolContext } from "../types.js"; import { LOCAL_CAPABILITIES } from "./local.js"; +import { createSandboxedCapabilities } from "./sandboxed.js"; +import { defaultSandboxSpec } from "../sandbox/sandbox.js"; +import { modeIsBounded, type SandboxMode } from "../sandbox/mode.js"; import type { Capabilities } from "./types.js"; +/** + * The execution world for a workspace when no caller supplied one. Resolved + * per call rather than cached: the mode comes from the environment, and a + * cached world would quietly outlive a change to it. + */ +export function defaultCapabilitiesFor( + cwd: string, + mode?: SandboxMode, +): Capabilities { + const spec = defaultSandboxSpec({ cwd, mode }); + if (!modeIsBounded(spec.mode)) return LOCAL_CAPABILITIES; + return createSandboxedCapabilities({ root: cwd, spec }); +} + export function capsOf(ctx: ToolContext): Capabilities { - return ctx.caps ?? LOCAL_CAPABILITIES; + // A caller-supplied world wins; otherwise resolve from the turn's pinned mode + // (H2 — a session's `header.sandboxMode`), falling back to the environment + // default only when nothing was pinned. This is the single point where the + // per-session pin actually takes effect for fs/shell tools. + return ctx.caps ?? defaultCapabilitiesFor(ctx.cwd, ctx.sandboxMode); } export { LOCAL_CAPABILITIES, localFs, localShell } from "./local.js"; +export { createSandboxedCapabilities } from "./sandboxed.js"; export { createMemoryCapabilities, createMemoryFs, refusingShell } from "./memory.js"; export type { Capabilities, diff --git a/src/capabilities/local.ts b/src/capabilities/local.ts index 6cf8be2a..a980452c 100644 --- a/src/capabilities/local.ts +++ b/src/capabilities/local.ts @@ -12,7 +12,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { spawn } from "node:child_process"; import { atomicWrite } from "../fs-utils.js"; -import { defaultSandboxSpec, wrapForSandbox } from "../sandbox/sandbox.js"; import type { Capabilities, ExecOptions, @@ -120,20 +119,10 @@ function runChild( export const localShell: ShellCapability = { async run(command: string, opts: ExecOptions): Promise { - // The opt-in LISA_SANDBOX wrapper moved here from the bash tool: which - // confinement a command runs under is a property of the execution world, - // not of the tool that asked. H2 replaces this with a real sandboxed - // provider (and closes the hole that fs writes never went through it); - // for now it is the previous behaviour, relocated unchanged. - const wrapped = await wrapForSandbox( - defaultSandboxSpec({ cwd: opts.cwd }), - command, - ); - try { - return await runChild(wrapped.command, wrapped.args, opts); - } finally { - await wrapped.cleanup?.(); - } + // Unconfined on purpose. Confinement is a property of the execution world, + // so it lives in the sandboxed provider that wraps this one — not in an + // `if` here that every future provider would have to remember to repeat. + return await runChild("/bin/bash", ["-lc", command], opts); }, async exec(file: string, args: string[], opts: ExecOptions): Promise { return await runChild(file, args, opts); diff --git a/src/capabilities/sandboxed.test.ts b/src/capabilities/sandboxed.test.ts new file mode 100644 index 00000000..01af8768 --- /dev/null +++ b/src/capabilities/sandboxed.test.ts @@ -0,0 +1,205 @@ +import { test, describe, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createSandboxedCapabilities } from "./sandboxed.js"; +import { capsOf, defaultCapabilitiesFor, LOCAL_CAPABILITIES } from "./index.js"; +import { writeTool } from "../tools/write.js"; +import { editTool } from "../tools/edit.js"; +import { applyPatchTool } from "../tools/apply_patch.js"; +import { readTool } from "../tools/read.js"; +import type { SandboxMode } from "../sandbox/mode.js"; +import type { ToolContext } from "../types.js"; + +/** + * H2 acceptance (docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3): the mutating fs tools + * are bounded by the same mode as the shell. Before H2 `wrapForSandbox` had one + * caller — the bash tool — so these three could write anywhere regardless. + */ + +let root: string; +let outside: string; + +before(() => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-sbx-")); + root = path.join(base, "workspace"); + outside = path.join(base, "elsewhere"); + fs.mkdirSync(root, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); +}); +after(() => { + fs.rmSync(path.dirname(root), { recursive: true, force: true }); +}); +beforeEach(() => { + delete process.env.LISA_SANDBOX; + delete process.env.LISA_SANDBOX_MODE; +}); + +/** + * `allowTemp: false` because the fixture's "outside" directory necessarily + * lives under os.tmpdir() on macOS, and temp is writable by default (see the + * provider's note on Seatbelt parity). Turning it off isolates the workspace + * boundary itself, which is what these cases are about; the default-on + * behaviour gets its own test below. + */ +function ctxFor(mode: SandboxMode, allowTemp = false): ToolContext { + return { + cwd: root, + signal: new AbortController().signal, + log: () => {}, + caps: createSandboxedCapabilities({ + root, + allowTemp, + spec: { mode, allowNetwork: true, cwd: root }, + }), + }; +} + +describe("workspace-write bounds the mutating fs tools", () => { + test("writes inside the workspace succeed", async () => { + const ctx = ctxFor("workspace-write"); + await writeTool.execute({ path: "inside.txt", content: "ok" }, ctx); + assert.equal(fs.readFileSync(path.join(root, "inside.txt"), "utf8"), "ok"); + }); + + test("write refuses a ../ escape", async () => { + const ctx = ctxFor("workspace-write"); + await assert.rejects( + writeTool.execute({ path: "../elsewhere/escaped.txt", content: "x" }, ctx), + /confines writes to/, + ); + assert.equal(fs.existsSync(path.join(outside, "escaped.txt")), false); + }); + + test("write refuses an absolute path outside the workspace", async () => { + const ctx = ctxFor("workspace-write"); + await assert.rejects( + writeTool.execute( + { path: path.join(outside, "abs.txt"), content: "x" }, + ctx, + ), + /confines writes to/, + ); + assert.equal(fs.existsSync(path.join(outside, "abs.txt")), false); + }); + + test("write refuses to follow a symlink out of the workspace", async () => { + const link = path.join(root, "escape-link"); + if (!fs.existsSync(link)) fs.symlinkSync(outside, link); + const ctx = ctxFor("workspace-write"); + await assert.rejects( + writeTool.execute({ path: "escape-link/through.txt", content: "x" }, ctx), + /confines writes to/, + "a path check that ignores symlinks would have let this through", + ); + assert.equal(fs.existsSync(path.join(outside, "through.txt")), false); + }); + + test("edit and apply_patch are bounded too, not just write", async () => { + const target = path.join(outside, "victim.txt"); + fs.writeFileSync(target, "original"); + const ctx = ctxFor("workspace-write"); + + await assert.rejects( + editTool.execute( + { path: target, old_string: "original", new_string: "tampered" }, + ctx, + ), + /confines writes to/, + ); + await assert.rejects( + applyPatchTool.execute( + { patches: [{ path: target, action: "update", content: "tampered" }] }, + ctx, + ), + /confines writes to/, + ); + await assert.rejects( + applyPatchTool.execute( + { patches: [{ path: target, action: "delete" }] }, + ctx, + ), + /confines writes to/, + ); + assert.equal(fs.readFileSync(target, "utf8"), "original", "untouched"); + }); + + test("reads outside the workspace still work — parity with Seatbelt's file-read*", async () => { + const readable = path.join(outside, "readable.txt"); + fs.writeFileSync(readable, "visible"); + const ctx = ctxFor("workspace-write"); + const out = await readTool.execute({ path: readable }, ctx); + assert.match(out, /visible/); + }); + + test("temp stays writable by default — bash can write there under the same mode", async () => { + const ctx = ctxFor("workspace-write", true); + const scratch = path.join(os.tmpdir(), `lisa-h2-scratch-${process.pid}.txt`); + try { + await writeTool.execute({ path: scratch, content: "scratch" }, ctx); + assert.equal(fs.readFileSync(scratch, "utf8"), "scratch"); + } finally { + fs.rmSync(scratch, { force: true }); + } + }); +}); + +describe("read-only forbids writes everywhere", () => { + test("even inside the workspace", async () => { + const ctx = ctxFor("read-only"); + await assert.rejects( + writeTool.execute({ path: "nope.txt", content: "x" }, ctx), + /forbids writes/, + ); + assert.equal(fs.existsSync(path.join(root, "nope.txt")), false); + }); + + test("reads are unaffected", async () => { + fs.writeFileSync(path.join(root, "readable.txt"), "hello"); + const ctx = ctxFor("read-only"); + assert.match(await readTool.execute({ path: "readable.txt" }, ctx), /hello/); + }); +}); + +describe("danger-full-access is the previous behaviour", () => { + test("writes anywhere are allowed", async () => { + const ctx = ctxFor("danger-full-access"); + const target = path.join(outside, "allowed.txt"); + await writeTool.execute({ path: target, content: "y" }, ctx); + assert.equal(fs.readFileSync(target, "utf8"), "y"); + }); +}); + +describe("the default world follows the resolved mode", () => { + test("no env set → the plain local world, byte-for-byte the old default", () => { + assert.equal(defaultCapabilitiesFor(root), LOCAL_CAPABILITIES); + }); + + test("LISA_SANDBOX=1 now bounds fs writes, not only bash", async () => { + process.env.LISA_SANDBOX = "1"; + const ctx: ToolContext = { + cwd: root, + signal: new AbortController().signal, + log: () => {}, + }; + assert.notEqual(capsOf(ctx), LOCAL_CAPABILITIES); + // Under the filesystem root: outside both the workspace and any temp dir, + // and unwritable anyway, so a regression here fails loudly instead of + // scribbling on the host. + await assert.rejects( + writeTool.execute( + { path: "/lisa-h2-must-not-exist/legacy.txt", content: "x" }, + ctx, + ), + /confines writes to/, + "this is the hole H2 closes: before, LISA_SANDBOX=1 bounded bash but not write", + ); + }); + + test("an explicitly supplied world is never overridden by the environment", () => { + process.env.LISA_SANDBOX_MODE = "read-only"; + const ctx = ctxFor("danger-full-access"); + assert.equal(capsOf(ctx), ctx.caps); + }); +}); diff --git a/src/capabilities/sandboxed.ts b/src/capabilities/sandboxed.ts new file mode 100644 index 00000000..b00a5675 --- /dev/null +++ b/src/capabilities/sandboxed.ts @@ -0,0 +1,153 @@ +/** + * Sandboxed provider (H2 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3). + * + * Before this, `wrapForSandbox` had exactly one caller: the `bash` tool. So + * with LISA_SANDBOX=1 a shell command was confined to cwd while `write`, + * `edit` and `apply_patch` — which resolve `path.resolve(ctx.cwd, input.path)` + * and accept absolute paths and `../` alike — could still reach the whole disk. + * A user who turned the sandbox on had a guarantee they did not have. + * + * Both halves now read one `SandboxMode` from one spec, which is what makes it + * structurally impossible for them to bound to different roots again. + * + * What each mode means here: + * + * | mode | fs reads | fs writes | shell | + * |---------------------|----------|----------------------|------------------| + * | read-only | anywhere | refused | no writable path | + * | workspace-write | anywhere | root + temp only | root + temp only | + * | danger-full-access | anywhere | anywhere | unconfined | + * + * Reads are deliberately unbounded in every mode, because Seatbelt's policy + * grants `file-read*` unconditionally: bounding fs reads while shell reads stay + * open would recreate the very asymmetry this fixes, with the added cost of + * breaking every tool that reads outside the workspace (~/.lisa included). + * "What may be read at all" is decided a layer up, by the tool subsets. + */ + +import os from "node:os"; +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 { modeIsBounded, modeAllowsWrites } from "../sandbox/mode.js"; + +export interface SandboxedOptions { + /** The workspace root writes are confined to under `workspace-write`. */ + root: string; + spec: SandboxSpec; + /** + * Whether the system temp directories count as writable under + * `workspace-write`. Defaults to true, matching the Seatbelt policy. + * + * This is a deliberate looseness, not an oversight: `bash` can write to /tmp + * under the same mode, so refusing `write` the same access would be theatre — + * and an fs bound that disagrees with the shell bound is precisely the defect + * H2 exists to remove. Set false only to exercise pure workspace confinement. + */ + allowTemp?: boolean; +} + +/** + * Is `abs` inside `root`? Compares resolved *real* paths so a symlink planted + * inside the workspace cannot be used to write through it to somewhere else. + * Since the target usually does not exist yet, it walks up to the nearest + * existing ancestor and realpaths that. + */ +async function isInside(abs: string, root: string): Promise { + const realRoot = await realpathOrSelf(root); + const realAbs = await realpathOfNearestExisting(abs); + return realAbs === realRoot || realAbs.startsWith(realRoot + path.sep); +} + +async function realpathOrSelf(p: string): Promise { + try { + return await fsp.realpath(p); + } catch { + return p; + } +} + +async function realpathOfNearestExisting(abs: string): Promise { + let current = abs; + const trailing: string[] = []; + // Bounded by path depth; a resolved path cannot loop. + for (;;) { + try { + const real = await fsp.realpath(current); + return trailing.length ? path.join(real, ...trailing.reverse()) : real; + } catch { + const parent = path.dirname(current); + if (parent === current) return abs; // hit the filesystem root + trailing.push(path.basename(current)); + current = parent; + } + } +} + +function writableRoots(opts: SandboxedOptions): string[] { + // Mirrors the Seatbelt policy's writable subpaths so the two halves agree. + if (opts.allowTemp === false) return [opts.root]; + return [opts.root, os.tmpdir(), "/tmp", "/private/tmp"]; +} + +function createSandboxedFs(opts: SandboxedOptions): FsCapability { + const mode = opts.spec.mode; + + const assertWritable = async (abs: string): Promise => { + if (!modeAllowsWrites(mode)) { + throw new Error( + `sandbox mode "${mode}" forbids writes — refusing to modify ${abs}`, + ); + } + if (!modeIsBounded(mode)) return; + for (const root of writableRoots(opts)) { + if (await isInside(abs, root)) return; + } + throw new Error( + `sandbox mode "${mode}" confines writes to ${opts.root} — refusing to write ${abs}`, + ); + }; + + return { + // Resolution only: the shell resolves nothing either, and reads are + // unbounded by design (see the module header). The gate is on the write + // operations, in one place, for every tool at once. + resolvePath: localFs.resolvePath, + stat: localFs.stat, + exists: localFs.exists, + readFile: localFs.readFile, + readdir: localFs.readdir, + async writeFile(abs: string, content: string): Promise { + await assertWritable(abs); + await localFs.writeFile(abs, content); + }, + async unlink(abs: string): Promise { + await assertWritable(abs); + await localFs.unlink(abs); + }, + }; +} + +export function createSandboxedCapabilities(opts: SandboxedOptions): Capabilities { + return { + fs: createSandboxedFs(opts), + shell: { + async run(command: string, execOpts: ExecOptions): Promise { + // Throws SANDBOX_UNAVAILABLE when the host cannot enforce the mode — + // the command does not run unconfined behind the user's back. + const wrapped = await wrapForSandbox( + { ...opts.spec, cwd: execOpts.cwd }, + command, + ); + try { + return await localShell.exec(wrapped.command, wrapped.args, execOpts); + } finally { + await wrapped.cleanup?.(); + } + }, + exec: localShell.exec, + }, + }; +} diff --git a/src/capabilities/seam.test.ts b/src/capabilities/seam.test.ts index e5ca8678..d286adb8 100644 --- a/src/capabilities/seam.test.ts +++ b/src/capabilities/seam.test.ts @@ -1,9 +1,10 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createMemoryCapabilities, refusingShell } from "./index.js"; +import { createMemoryCapabilities, refusingShell, capsOf } from "./index.js"; import { readTool } from "../tools/read.js"; import { writeTool } from "../tools/write.js"; import { editTool } from "../tools/edit.js"; @@ -185,3 +186,51 @@ describe("a world without processes says so", () => { await assert.rejects(refusingShell.exec("x", [], { cwd: "/" }), /unavailable/); }); }); + +describe("a turn's pinned sandbox mode is enforced, not just recorded (H2)", () => { + // The bug this guards: `ctx.sandboxMode` was written to the session header but + // never read for enforcement — `capsOf` re-resolved from `process.env` every + // call, so an explicit pin did nothing and two sessions could not differ. + test("a read-only pin refuses writes even when the env default is full-access", async () => { + const prev = process.env.LISA_SANDBOX_MODE; + process.env.LISA_SANDBOX_MODE = "danger-full-access"; // process-wide default + const target = path.join(os.tmpdir(), `lisa-ro-pin-${process.pid}.txt`); + fs.rmSync(target, { force: true }); + try { + const ctx: ToolContext = { + cwd: os.tmpdir(), + signal: new AbortController().signal, + log: () => {}, + sandboxMode: "read-only", // the per-turn pin + }; + await assert.rejects( + writeTool.execute({ path: path.basename(target), content: "x" }, ctx), + /forbids writes|read-only/, + "the pin must win over the env default", + ); + assert.equal(fs.existsSync(target), false, "nothing was written"); + } finally { + fs.rmSync(target, { force: true }); + if (prev === undefined) delete process.env.LISA_SANDBOX_MODE; + else process.env.LISA_SANDBOX_MODE = prev; + } + }); + + test("capsOf resolves the pinned mode's world, not the environment's", () => { + const prev = process.env.LISA_SANDBOX_MODE; + process.env.LISA_SANDBOX_MODE = "danger-full-access"; + try { + const base = { cwd: os.tmpdir(), signal: new AbortController().signal, log: () => {} }; + const pinned = capsOf({ ...base, sandboxMode: "read-only" }); + const envDefault = capsOf(base); // no pin ⇒ env ⇒ full-access ⇒ plain local world + assert.notEqual( + pinned.fs, + envDefault.fs, + "a bounded pin must select a different (sandboxed) fs than the unconfined env default", + ); + } finally { + if (prev === undefined) delete process.env.LISA_SANDBOX_MODE; + else process.env.LISA_SANDBOX_MODE = prev; + } + }); +}); diff --git a/src/channels/router.ts b/src/channels/router.ts index 98a44343..409fc339 100644 --- a/src/channels/router.ts +++ b/src/channels/router.ts @@ -27,6 +27,13 @@ export interface RouterOptions { * Falls back to `tools` when absent. */ toolsFor?: (channelName: string) => ToolDefinition[]; + /** + * Sandbox mode for channel turns (H2). Channels are remote-origin/untrusted; + * an operator exposing fs/shell to a channel (`unsafeFullTools`) should pin a + * bounded mode here (e.g. "workspace-write") so a prompt-injected message + * can't reach past the workspace. Unset ⇒ the environment default. + */ + sandboxMode?: import("../sandbox/mode.js").SandboxMode; } interface ThreadContext { @@ -128,6 +135,7 @@ export class ChannelRouter { cwd: this.opts.cwd, signal: this.opts.signal, log: () => {}, + sandboxMode: this.opts.sandboxMode, }, history: ctx.history, userMessage: msg.text, diff --git a/src/cli.ts b/src/cli.ts index e2b67498..3b1acb11 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -715,7 +715,9 @@ async function main(): Promise { provider, systemPrompt: fresh.text, tools: composedTools, - toolCtx: { cwd, signal: abortController.signal, log: () => {} }, + // Pin the turn to the session's mode, frozen at creation (H2), so the + // sandbox can't be widened mid-session by a later env change. + toolCtx: { cwd, signal: abortController.signal, log: () => {}, sandboxMode: session.header.sandboxMode }, history, userMessage: prompt, model: args.model, diff --git a/src/sandbox/macos.ts b/src/sandbox/macos.ts index 1b7e34f6..675d803e 100644 --- a/src/sandbox/macos.ts +++ b/src/sandbox/macos.ts @@ -1,7 +1,19 @@ +import type { SandboxMode } from "./mode.js"; + +/** + * Seatbelt policy for a given mode. + * + * `read-only` grants no writable path at all except the null device: a command + * that cannot write cannot be given a scratch directory "for convenience" + * without the mode becoming a lie. `workspace-write` keeps the previous + * behaviour (cwd + the temp directories a normal toolchain needs). + */ export function buildMacosSeatbeltPolicy(opts: { cwd: string; allowNetwork: boolean; + mode?: SandboxMode; }): string { + const mode: SandboxMode = opts.mode ?? "workspace-write"; const lines: string[] = [ "(version 1)", "(deny default)", @@ -14,12 +26,20 @@ export function buildMacosSeatbeltPolicy(opts: { "(allow file-read-metadata)", "(allow mach-lookup)", "(allow ipc-posix-shm)", - "(allow file-write* (subpath \"/tmp\"))", - "(allow file-write* (subpath \"/private/tmp\"))", - "(allow file-write* (subpath \"/var/folders\"))", - "(allow file-write* (subpath \"/private/var/folders\"))", - `(allow file-write* (subpath ${jsonString(opts.cwd)}))`, ]; + if (mode === "workspace-write") { + lines.push( + "(allow file-write* (subpath \"/tmp\"))", + "(allow file-write* (subpath \"/private/tmp\"))", + "(allow file-write* (subpath \"/var/folders\"))", + "(allow file-write* (subpath \"/private/var/folders\"))", + `(allow file-write* (subpath ${jsonString(opts.cwd)}))`, + ); + } else { + // read-only: writing to /dev/null is what "no writes" means in practice — + // countless tools redirect there and would otherwise die on startup. + lines.push("(allow file-write-data (literal \"/dev/null\"))"); + } if (opts.allowNetwork) { lines.push("(allow network*)"); } else { diff --git a/src/sandbox/mode.test.ts b/src/sandbox/mode.test.ts new file mode 100644 index 00000000..92bbf4ce --- /dev/null +++ b/src/sandbox/mode.test.ts @@ -0,0 +1,137 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + SandboxUnavailableError, + isSandboxMode, + modeAllowsWrites, + modeIsBounded, + resolveSandboxMode, +} from "./mode.js"; +import { buildMacosSeatbeltPolicy } from "./macos.js"; +import { wrapForSandbox } from "./sandbox.js"; + +/** H2 acceptance (docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3). */ + +beforeEach(() => { + delete process.env.LISA_SANDBOX; + delete process.env.LISA_SANDBOX_MODE; +}); + +describe("sandbox mode resolution", () => { + test("defaults to danger-full-access — H2 does not silently confine existing setups", () => { + assert.equal(resolveSandboxMode(), "danger-full-access"); + }); + + test("legacy LISA_SANDBOX=1 still means workspace-write", () => { + process.env.LISA_SANDBOX = "1"; + assert.equal(resolveSandboxMode(), "workspace-write"); + process.env.LISA_SANDBOX = "true"; + assert.equal(resolveSandboxMode(), "workspace-write"); + }); + + test("LISA_SANDBOX_MODE wins over the legacy flag", () => { + process.env.LISA_SANDBOX = "1"; + process.env.LISA_SANDBOX_MODE = "read-only"; + assert.equal(resolveSandboxMode(), "read-only"); + }); + + test("an explicit argument wins over the environment", () => { + process.env.LISA_SANDBOX_MODE = "read-only"; + assert.equal(resolveSandboxMode("workspace-write"), "workspace-write"); + }); + + test("a typo in LISA_SANDBOX_MODE is an error, not a silent fallback", () => { + process.env.LISA_SANDBOX_MODE = "workspace_write"; + assert.throws(() => resolveSandboxMode(), /bad LISA_SANDBOX_MODE/); + }); + + test("mode predicates", () => { + assert.equal(modeAllowsWrites("read-only"), false); + assert.equal(modeAllowsWrites("workspace-write"), true); + assert.equal(modeIsBounded("danger-full-access"), false); + assert.equal(modeIsBounded("read-only"), true); + assert.equal(isSandboxMode("read-only"), true); + assert.equal(isSandboxMode("nonsense"), false); + }); +}); + +describe("macOS Seatbelt policy per mode", () => { + test("workspace-write grants the workspace and temp dirs", () => { + const policy = buildMacosSeatbeltPolicy({ + cwd: "/work/proj", + allowNetwork: true, + mode: "workspace-write", + }); + assert.match(policy, /\(allow file-write\* \(subpath "\/work\/proj"\)\)/); + assert.match(policy, /\(subpath "\/tmp"\)/); + }); + + test("read-only grants no writable path but /dev/null", () => { + const policy = buildMacosSeatbeltPolicy({ + cwd: "/work/proj", + allowNetwork: true, + mode: "read-only", + }); + assert.doesNotMatch(policy, /subpath "\/work\/proj"/); + assert.doesNotMatch(policy, /file-write\* \(subpath "\/tmp"\)/); + assert.match(policy, /file-write-data \(literal "\/dev\/null"\)/); + }); + + test("network can be withheld in either mode", () => { + for (const mode of ["read-only", "workspace-write"] as const) { + const policy = buildMacosSeatbeltPolicy({ + cwd: "/w", + allowNetwork: false, + mode, + }); + assert.doesNotMatch(policy, /^\(allow network\*\)$/m); + assert.match(policy, /local tcp "localhost:\*"/); + } + }); +}); + +describe("wrapForSandbox — fail closed, never silently unconfined", () => { + test("danger-full-access runs plain bash", async () => { + const wrapped = await wrapForSandbox( + { mode: "danger-full-access", allowNetwork: true, cwd: "/w" }, + "echo hi", + ); + assert.equal(wrapped.command, "/bin/bash"); + assert.deepEqual(wrapped.args, ["-lc", "echo hi"]); + }); + + test("a bounded mode on an unsupported platform refuses instead of degrading", async (t) => { + if (process.platform === "darwin") { + // The real macOS path is exercised below; simulate the other branch. + const original = Object.getOwnPropertyDescriptor(process, "platform")!; + t.after(() => Object.defineProperty(process, "platform", original)); + Object.defineProperty(process, "platform", { value: "sunos" }); + } + await assert.rejects( + wrapForSandbox( + { mode: "workspace-write", allowNetwork: true, cwd: "/w" }, + "echo hi", + ), + (err: unknown) => { + assert.ok(err instanceof SandboxUnavailableError); + assert.equal(err.code, "SANDBOX_UNAVAILABLE"); + assert.match(err.message, /Refusing to run the command unconfined/); + return true; + }, + ); + }); + + test("macOS wraps in sandbox-exec and cleans up the policy file", async (t) => { + if (process.platform !== "darwin") return t.skip("darwin only"); + const wrapped = await wrapForSandbox( + { mode: "workspace-write", allowNetwork: true, cwd: process.cwd() }, + "echo hi", + ); + assert.equal(wrapped.command, "/usr/bin/sandbox-exec"); + assert.equal(wrapped.args[0], "-f"); + const { existsSync } = await import("node:fs"); + assert.equal(existsSync(wrapped.args[1]!), true, "policy file written"); + await wrapped.cleanup?.(); + assert.equal(existsSync(wrapped.args[1]!), false, "policy file removed"); + }); +}); diff --git a/src/sandbox/mode.ts b/src/sandbox/mode.ts new file mode 100644 index 00000000..ea7f2dbb --- /dev/null +++ b/src/sandbox/mode.ts @@ -0,0 +1,82 @@ +/** + * Sandbox modes (H2 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3). + * + * Three modes, named after dsh's rather than inventing a vocabulary. They are a + * property of the *execution world*, so both the filesystem and the shell + * provider read the same one — the old arrangement, where `bash` was confined + * to cwd while `write` could reach the whole disk, is exactly what a single + * shared mode makes impossible to express. + * + * Enforcement is split by what each layer can actually guarantee: + * + * - filesystem — enforced in-process at `resolvePath`, so it works on every + * platform with no external dependency; + * - shell — enforced by the OS (Seatbelt on macOS, bubblewrap on Linux). When + * no mechanism is available the request FAILS rather than silently running + * unconfined. "Thinking the sandbox is on when it is off" is worse than + * knowing there is none. + */ + +export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; + +export const SANDBOX_MODES: readonly SandboxMode[] = [ + "read-only", + "workspace-write", + "danger-full-access", +]; + +export function isSandboxMode(v: unknown): v is SandboxMode { + return typeof v === "string" && (SANDBOX_MODES as readonly string[]).includes(v); +} + +/** + * Thrown when a mode asks for confinement the host cannot enforce. Carries a + * stable `code` so callers can distinguish "refused on purpose" from a random + * spawn failure. + */ +export class SandboxUnavailableError extends Error { + readonly code = "SANDBOX_UNAVAILABLE"; + constructor(message: string) { + super(message); + this.name = "SandboxUnavailableError"; + } +} + +/** + * Resolve the mode for a new execution world. + * + * Precedence: explicit argument > `LISA_SANDBOX_MODE` > legacy `LISA_SANDBOX=1` + * > `danger-full-access`. + * + * The default is deliberately unchanged from before H2: an attended local REPL + * is the same trust posture as typing into a shell, and silently confining + * everyone's existing setup is not this change's job. What H2 fixes is that + * turning the sandbox ON now actually bounds file writes too. Tightening the + * default for *unattended* surfaces (dispatch, idle, channels) is a separate, + * behaviour-changing step — see the plan's §3 table. + */ +export function resolveSandboxMode(explicit?: SandboxMode): SandboxMode { + if (explicit) return explicit; + const named = process.env.LISA_SANDBOX_MODE; + if (named) { + if (!isSandboxMode(named)) { + throw new Error( + `bad LISA_SANDBOX_MODE "${named}" — expected one of ${SANDBOX_MODES.join(" | ")}`, + ); + } + return named; + } + const legacy = process.env.LISA_SANDBOX; + if (legacy === "1" || legacy === "true") return "workspace-write"; + return "danger-full-access"; +} + +/** Does this mode allow writing at all? */ +export function modeAllowsWrites(mode: SandboxMode): boolean { + return mode !== "read-only"; +} + +/** Does this mode bound where reads and writes may land? */ +export function modeIsBounded(mode: SandboxMode): boolean { + return mode !== "danger-full-access"; +} diff --git a/src/sandbox/sandbox.ts b/src/sandbox/sandbox.ts index 7a280a9f..6246e5b5 100644 --- a/src/sandbox/sandbox.ts +++ b/src/sandbox/sandbox.ts @@ -2,10 +2,17 @@ import os from "node:os"; import fs from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; import { buildMacosSeatbeltPolicy } from "./macos.js"; +import { + SandboxUnavailableError, + modeIsBounded, + resolveSandboxMode, + type SandboxMode, +} from "./mode.js"; export interface SandboxSpec { - enabled: boolean; + mode: SandboxMode; allowNetwork: boolean; cwd: string; } @@ -16,17 +23,29 @@ export interface SandboxedCommand { cleanup?: () => Promise; } +/** + * Wrap a shell command in whatever confinement the host can actually enforce + * for `spec.mode`. + * + * The one hard rule: when the mode asks for confinement and no mechanism is + * available, this THROWS `SANDBOX_UNAVAILABLE`. It used to return a plain + * `/bin/bash -lc` on Linux, which meant a user who set LISA_SANDBOX=1 on Linux + * got no sandbox and no indication — the failure mode where you act on a + * guarantee you do not have. + */ export async function wrapForSandbox( spec: SandboxSpec, shellCommand: string, ): Promise { - if (!spec.enabled) { + if (!modeIsBounded(spec.mode)) { return { command: "/bin/bash", args: ["-lc", shellCommand] }; } + if (process.platform === "darwin") { const policy = buildMacosSeatbeltPolicy({ cwd: spec.cwd, allowNetwork: spec.allowNetwork, + mode: spec.mode, }); const tmp = path.join( os.tmpdir(), @@ -43,14 +62,68 @@ export async function wrapForSandbox( }, }; } - // No portable sandbox on linux without bwrap/landlock helpers — degrade. - return { command: "/bin/bash", args: ["-lc", shellCommand] }; + + if (process.platform === "linux" && hasBubblewrap()) { + return { command: "bwrap", args: [...bwrapArgs(spec), "/bin/bash", "-lc", shellCommand] }; + } + + throw new SandboxUnavailableError( + `sandbox mode "${spec.mode}" cannot be enforced on ${process.platform}: ` + + (process.platform === "linux" + ? "bubblewrap (bwrap) is not installed. Install it (apt install bubblewrap), " + : "no supported confinement mechanism, ") + + `or set LISA_SANDBOX_MODE=danger-full-access to run unconfined on purpose. ` + + `Refusing to run the command unconfined while a sandbox was requested.`, +); +} + +/** + * bubblewrap invocation for a bounded mode: a read-only bind of the whole + * filesystem, then the writable paths the mode grants layered on top. + * + * Untested on this project's CI (macOS host); the fail-closed path above is + * what protects a Linux user if this is wrong — a bad invocation makes bwrap + * exit non-zero, which surfaces, rather than silently running unconfined. + */ +function bwrapArgs(spec: SandboxSpec): string[] { + const args = [ + "--ro-bind", "/", "/", + "--dev", "/dev", + "--proc", "/proc", + "--die-with-parent", + ]; + if (spec.mode === "workspace-write") { + args.push("--bind", spec.cwd, spec.cwd); + args.push("--bind", os.tmpdir(), os.tmpdir()); + } + if (!spec.allowNetwork) args.push("--unshare-net"); + args.push("--chdir", spec.cwd); + return args; } -export function defaultSandboxSpec(opts: { cwd: string }): SandboxSpec { - const env = process.env.LISA_SANDBOX; +let bubblewrapChecked: boolean | undefined; +function hasBubblewrap(): boolean { + if (bubblewrapChecked !== undefined) return bubblewrapChecked; + try { + const probe = spawnSync("bwrap", ["--version"], { stdio: "ignore" }); + bubblewrapChecked = probe.status === 0; + } catch { + bubblewrapChecked = false; + } + return bubblewrapChecked; +} + +/** Test hook — the bwrap probe is cached for the process lifetime. */ +export function _resetBubblewrapProbeForTest(): void { + bubblewrapChecked = undefined; +} + +export function defaultSandboxSpec(opts: { + cwd: string; + mode?: SandboxMode; +}): SandboxSpec { return { - enabled: env === "1" || env === "true", + mode: resolveSandboxMode(opts.mode), allowNetwork: process.env.LISA_SANDBOX_NETWORK !== "0", cwd: opts.cwd, }; diff --git a/src/sessions/store.test.ts b/src/sessions/store.test.ts index bbbc295e..8b710516 100644 --- a/src/sessions/store.test.ts +++ b/src/sessions/store.test.ts @@ -54,6 +54,34 @@ describe("SessionStore — create / open round-trip", () => { await assert.rejects(SessionStore.open("does-not-exist")); }); + // H2 — the posture is fixed at creation, so editing a setting mid-flight + // cannot widen what an already-running task may do. + test("the sandbox mode is recorded in the header and survives resume", async () => { + const s = await SessionStore.create({ + cwd: "/w", + model: "m", + sandboxMode: "workspace-write", + }); + assert.equal(s.header.sandboxMode, "workspace-write"); + + process.env.LISA_SANDBOX_MODE = "danger-full-access"; + try { + const resumed = await SessionStore.open(s.id); + assert.equal( + resumed.header.sandboxMode, + "workspace-write", + "a later setting change must not retroactively widen this session", + ); + } finally { + delete process.env.LISA_SANDBOX_MODE; + } + }); + + test("without an explicit mode the header records the resolved default", async () => { + const s = await SessionStore.create({ cwd: "/w", model: "m" }); + assert.equal(s.header.sandboxMode, "danger-full-access"); + }); + test("open() rejects an empty session file", async () => { const empty = path.join(home, "sessions", "empty-one.jsonl"); fs.mkdirSync(path.dirname(empty), { recursive: true }); diff --git a/src/sessions/store.ts b/src/sessions/store.ts index 8774fcf6..ddf65826 100644 --- a/src/sessions/store.ts +++ b/src/sessions/store.ts @@ -3,6 +3,7 @@ import path from "node:path"; import crypto from "node:crypto"; import { sessionsDir } from "../paths.js"; import { appendLine, ensureDir } from "../fs-utils.js"; +import { resolveSandboxMode, type SandboxMode } from "../sandbox/mode.js"; import type { SessionEntry, SessionHeader, StoredMessage } from "../types.js"; /** Content hash of a system prompt — the identity of a `prompt` entry. */ @@ -46,6 +47,8 @@ export class SessionStore { static async create(opts: { cwd: string; model: string; + /** Overrides the environment-resolved mode (H2). */ + sandboxMode?: SandboxMode; }): Promise { // Session logs now carry the full system prompt — soul, USER.md, MEMORY.md, // KB — the same sensitive user context the rest of ~/.lisa keeps private, so @@ -63,6 +66,10 @@ export class SessionStore { startedAt: new Date().toISOString(), cwd: opts.cwd, model: opts.model, + // Resolved once, here. A session carries the posture it was created + // under, so editing a setting cannot widen what a task already running + // under the old one is permitted to do. + sandboxMode: resolveSandboxMode(opts.sandboxMode), }; await appendLine(file, JSON.stringify(header)); await fs.chmod(file, 0o600).catch(() => {}); diff --git a/src/subagent.ts b/src/subagent.ts index 64dfbad7..c459a45f 100644 --- a/src/subagent.ts +++ b/src/subagent.ts @@ -25,6 +25,13 @@ export interface SubagentOptions { * default says so; idle / heartbeat pass something more specific. */ moodOrigin?: string; + /** + * Sandbox mode for this subagent's tools (H2). Unset ⇒ the environment + * default. Pass the parent turn's mode so a subagent cannot escape the + * confinement its caller runs under; unattended/untrusted callers (channels, + * idle, heartbeat, feed/mail classification) may pin a bounded mode. + */ + sandboxMode?: import("./sandbox/mode.js").SandboxMode; } export interface SubagentResult { @@ -48,6 +55,7 @@ export async function runSubagent(opts: SubagentOptions): Promise {}), + sandboxMode: opts.sandboxMode, }, history: [], userMessage: opts.prompt, diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 92a97bde..c7b77620 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -15,7 +15,8 @@ export const bashTool: ToolDefinition = { description: "Run a shell command via /bin/bash and return its stdout, stderr, and exit code. " + "Use this for git operations, package managers, build scripts, file inspection (head/tail/wc), and one-off scripts. " + - "When LISA_SANDBOX=1 the command runs under macOS sandbox-exec restricting writes to cwd + /tmp. " + + "Under a bounded sandbox mode the command is confined by the OS (macOS Seatbelt / Linux bubblewrap) " + + "to the same roots the file tools are; on a platform where that cannot be enforced the command is refused. " + "Long outputs are truncated to 64KB. Default timeout is 60s; max 600s.", inputSchema: { type: "object", diff --git a/src/types.ts b/src/types.ts index 5a02d295..36478c06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,14 @@ export interface ToolContext { * directly, so the local default is applied in exactly one place. */ caps?: import("./capabilities/types.js").Capabilities; + /** + * The sandbox mode this turn is pinned to (H2). When `caps` is unset, this is + * what `capsOf` resolves the world from — so a session pinned to `read-only` + * confines its writes/shell even though the process-wide `LISA_SANDBOX_MODE` + * default says otherwise, and two concurrent sessions can differ. Unset ⇒ the + * environment default (`resolveSandboxMode()` re-read per call). + */ + sandboxMode?: import("./sandbox/mode.js").SandboxMode; } export type StoredMessage = Anthropic.MessageParam; @@ -73,6 +81,15 @@ export interface SessionHeader { startedAt: string; cwd: string; model: string; + /** + * The sandbox mode this session runs under (H2). Fixed when the session is + * created and never re-read: changing a setting mid-flight must not silently + * widen — or narrow — what an already-running task is allowed to do. + * + * Optional because sessions written before H2 do not have it; absent means + * "not recorded", not "unconfined". + */ + sandboxMode?: import("./sandbox/mode.js").SandboxMode; } export type SessionEntry =