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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 39 additions & 12 deletions src/capabilities/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
19 changes: 4 additions & 15 deletions src/capabilities/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,20 +119,10 @@ function runChild(

export const localShell: ShellCapability = {
async run(command: string, opts: ExecOptions): Promise<ExecResult> {
// 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<ExecResult> {
return await runChild(file, args, opts);
Expand Down
205 changes: 205 additions & 0 deletions src/capabilities/sandboxed.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading