Skip to content

feat(sandbox): 三档模式 + fail-closed + 会话级固定(H2) - #359

Merged
oratis merged 2 commits into
mainfrom
claude/h2-sandbox-modes
Aug 14, 2026
Merged

feat(sandbox): 三档模式 + fail-closed + 会话级固定(H2)#359
oratis merged 2 commits into
mainfrom
claude/h2-sandbox-modes

Conversation

@oratis

@oratis oratis commented Aug 13, 2026

Copy link
Copy Markdown
Owner

栈式 PR:base 是 #358(H1),不是 main。 计划见 #356docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §3。
合并 #358请勿加 --delete-branch —— 会连带关掉本 PR。

三个洞,按严重度

1. write / edit / apply_patch 此前完全不受沙箱约束。

wrapForSandbox 唯一调用点是 bash 工具,而三个写工具都是 path.resolve(ctx.cwd, input.path),绝对路径与 ../ 一律照收。也就是说 LISA_SANDBOX=1bash 被关进 cwd、write 能写满盘 —— 用户拿到了一个自己并不拥有的保证。

2. 非 macOS 静默降级成不受限 /bin/bash -lc。现在无法强制时抛 SANDBOX_UNAVAILABLE 并拒绝执行。"以为沙箱开着其实没开"比"知道没有沙箱"更危险。

3. 权限没有会话级固定 —— 改设置会影响正在跑的任务。

三档(沿用 dsh 的词汇,不自造)

mode 文件读 文件写 shell
read-only 任意 拒绝 无可写路径
workspace-write 任意 工作区 + 临时目录 工作区 + 临时目录
danger-full-access(默认) 任意 任意 不受限

两个刻意的决定(都写进了代码注释,不是疏漏):

  • 读在所有模式下都不设界。 Seatbelt 无条件放行 file-read*;若只把 fs 的读收紧,就重新制造了 H2 要消灭的那种不对称,还会弄坏一切读工作区之外(含 ~/.lisa)的工具。"能不能读"由上一层的工具子集决定。
  • 临时目录在 workspace-write 下默认可写。 同一模式下 bash 本来就能写 /tmp,再去拦 write 是表演不是边界。

默认值不变

默认仍是 danger-full-access:本地有人值守的 REPL 与往 shell 里敲命令是同一信任姿态,静默收紧所有人的现有配置不是本次改动的职责。本次修的是"把沙箱打开时它真的管住文件写"。

按 surface 收紧无人值守默认值(计划 §3 的表:dispatch→workspace-write、idle/渠道→read-only)会改行为,另开 PR

实现

落在 #358 的 seam 上:sandboxed 提供方包住 local 提供方,capsOf 按解析出的模式选世界 —— 所以 LISA_SANDBOX=1 现在自动覆盖全部 fs/shell 工具而不只是 bash。符号链接逃逸用"最近存在祖先的 realpath"处理(纯路径检查会放过它)。

测试(27 例)

../ 逃逸 / 绝对路径逃逸 / 符号链接穿透 / editapply_patch 的 update 与 delete / read-only 连工作区内也拒 / 读不受影响 / 临时目录默认可写 / 不支持平台 fail-closed / Seatbelt 策略逐档断言 / 模式解析优先级 / 拼写错误报错而非静默回退 / 会话级固定不被后续设置变更加宽。

本机实测LISA_SANDBOX=1,目标 ~/lisa-h2-probe.txt):

bash  → touch: Operation not permitted   (Seatbelt)
write → 拒绝:sandbox mode "workspace-write" confines writes to <workspace>

此前第二行会是 ALLOWED。全量 1592 通过 / 0 失败

#357 的交互

两者都改 SessionHeader#357version: 2,本 PR 加可选的 sandboxMode。互不冲突(可选字段无需版本位),但 types.ts 会有一处文本冲突,取并集即可。

🤖 Generated with Claude Code

@oratis

oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

Security review — 2 HIGH ship-blockers; recommend NOT merging until enforcement is wired

The fail-closed plumbing here is genuinely well done and should be kept: wrapForSandbox throws SandboxUnavailableError on an unsupported platform / missing bwrap and the sandboxed provider does not catch it (no silent unconfined fallback); the symlink write-escape is really blocked (realpathOfNearestExisting + atomicWrite renames over a planted link); resolveSandboxMode throws on a typo rather than defaulting; and read-only Seatbelt grants no writable path but /dev/null. But two HIGH issues make the PR's headline guarantee false, so I've held this PR (merged the rest of the batch — H1 #358, H3 #357, P1 #360, plan #356) rather than ship a sandbox that doesn't confine.

HIGH-1 — session pinning (会话级固定) is recorded but never enforced

header.sandboxMode has exactly one producer (sessions/store.ts:48) and zero enforcement consumersgrep sandboxMode src/ outside tests/types confirms it. Every tool call resolves its world through capsOf(ctx) = ctx.caps ?? defaultCapabilitiesFor(ctx.cwd) with no mode, and ctx.caps is never assigned in non-test src/ — so defaultSandboxSpec re-reads process.env.LISA_SANDBOX_MODE fresh on every call. Consequences:

  • An explicit SessionStore.create({ sandboxMode: "read-only" }) pin does nothing — the next write/bash re-derives danger-full-access from env and runs unconfined. The store test only proves the header string round-trips, not that enforcement honors it.
  • The README's "fixed when a session is created … changing the setting never widens what an already-running task may do" is false: flip the env mid-flight and the next tool call uses the new mode.
  • Because the mode is a process-global env var, concurrent sessions can't have different modes — the exact opposite of "会话级固定."

Fix: thread the pinned mode into the context that reaches tools — carry sandboxMode on ToolContext, have capsOf use defaultCapabilitiesFor(ctx.cwd, ctx.sandboxMode), and set it from session.header.sandboxMode at the turn-build sites (web/server.ts:3971, cli.ts:718, subagent.ts:47, agents/managed.ts:170, channels/router.ts:127). Add a test that a read-only-pinned session actually rejects a write through writeTool.

HIGH-2 — default mode is danger-full-access, including on untrusted-input surfaces

sandbox/mode.ts:71 defaults to fully-unconfined, and (given HIGH-1) that's what everything runs under. The surfaces processing the least-trusted input — channels/router.ts (inbound Slack/DM), idle/heartbeat runners, mail + KB-feed classification — create contexts with no mode, so a prompt injection from an inbound message or a fetched RSS/WeChat/YouTube page drives write/bash with zero confinement.

Fix: default the unattended/untrusted surfaces to at least workspace-write (pass an explicit sandboxMode when those sessions/contexts are created), keeping the interactive danger-full-access default for the local user. (Depends on HIGH-1 so the pin is honored.)

MED-3 — shell.exec bypasses the sandbox in every bounded mode

capabilities/sandboxed.ts:150 wraps only run; exec is localShell.exec (unconfined). grep uses exec, so in read-only/workspace-write it runs with no OS confinement — harmless today (grep only reads) but it's the "one call site forgot the wrapper" invariant-break H2 exists to eliminate. Route exec through the same confinement as run.

Recommendation

The gap is wiring, not architecture — the sandbox providers, fail-closed, and symlink defenses are solid. But the wiring is cross-cutting and security-critical (6 context-build sites + defaulting the untrusted surfaces + a real enforcement test), and it should be done with full attention, not squeezed in. I recommend landing it as a focused follow-up PR on top of the now-merged H1. Holding #359 open (base still on the H1 branch) until then.

oratis and others added 2 commits August 14, 2026 18:24
按严重度,H2 修的三个洞:

**1. write / edit / apply_patch 此前完全不受沙箱约束。**
`wrapForSandbox` 唯一调用点是 bash 工具,而三个写工具都是
`path.resolve(ctx.cwd, input.path)`,绝对路径与 `../` 一律照收。也就是说
`LISA_SANDBOX=1` 时 bash 被关进 cwd、write 能写满盘——用户拿到了一个自己
并不拥有的保证。现在两边读同一个 SandboxMode、同一份规格,从结构上不可能
再被限制到不同的根目录。

**2. 非 macOS 静默降级成不受限 `/bin/bash -lc`。**
现在无法强制时抛 `SANDBOX_UNAVAILABLE` 并拒绝执行。"以为沙箱开着其实没开"
比"知道没有沙箱"更危险。Linux 走 bubblewrap(有则用),否则拒绝并给出可
操作的出路(装 bwrap,或显式 `LISA_SANDBOX_MODE=danger-full-access`)。

**3. 权限没有会话级固定。** 模式在会话创建时解析并写进 SessionHeader,
之后改设置不影响已在跑的会话。

三档沿用 dsh 的词汇,不自造:

| mode | 文件读 | 文件写 | shell |
|---|---|---|---|
| read-only | 任意 | 拒绝 | 无可写路径 |
| workspace-write | 任意 | 工作区 + 临时目录 | 工作区 + 临时目录 |
| danger-full-access(默认) | 任意 | 任意 | 不受限 |

两个刻意的决定,都写进了代码注释:

- **读在所有模式下都不设界**。Seatbelt 的策略无条件放行 `file-read*`,若
  只把 fs 的读收紧,就重新制造了 H2 要消灭的那种不对称,还会弄坏一切读
  工作区之外(含 ~/.lisa)的工具。"能不能读"由上一层的工具子集决定。
- **临时目录在 workspace-write 下默认可写**。同一模式下 bash 本来就能写
  /tmp,再去拦 write 是表演不是边界。`allowTemp: false` 仅供测试隔离。

默认值**保持不变**(danger-full-access):本地有人值守的 REPL 与往 shell
里敲命令是同一信任姿态,静默收紧所有人的现有配置不是本次改动的职责。本次
修的是"把沙箱打开时它真的管住文件写"。按 surface 收紧无人值守默认值(计划
§3 的表)是单独一步,会改行为,另开。

实现落在 H1 的 seam 上:sandboxed 提供方包住 local 提供方,`capsOf` 按解析
出的模式选世界——所以 `LISA_SANDBOX=1` 现在自动覆盖全部 fs/shell 工具,
而不只是 bash。符号链接逃逸用"最近存在祖先的 realpath"处理,纯路径检查会
放过它。

测试 27 例:../ 逃逸、绝对路径逃逸、**符号链接穿透**、edit/apply_patch 的
update 与 delete、read-only 连工作区内也拒、读不受影响、临时目录默认可写、
不支持平台 fail-closed、Seatbelt 策略逐档断言、模式解析优先级、拼写错误报错
而非静默回退、会话级固定不被后续设置变更加宽。

本机实测:LISA_SANDBOX=1 下 bash 与 write 同时被拒(此前 write 会成功),
两边错误信息一致指向同一个根目录。全量 1592 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ead)

The ship-blocker: header.sandboxMode was written but had zero enforcement
consumers — capsOf re-resolved from process.env every call, so an explicit pin
did nothing and concurrent sessions couldn't differ. Now:

- ToolContext carries sandboxMode; capsOf resolves the world from it
  (defaultCapabilitiesFor(cwd, ctx.sandboxMode)), falling back to the env
  default only when nothing is pinned. This is the single point where a pin
  actually takes effect. Enforcement test added: a read-only pin refuses writes
  even when LISA_SANDBOX_MODE=danger-full-access (the pin wins over env).
- cli threads session.header.sandboxMode → the mode is frozen at session
  creation and can't be widened mid-session by an env change.
- subagent + channel router carry sandboxMode so a subagent can't escape its
  caller's confinement and an operator can pin channels (untrusted remote input).

Follow-ups (documented on the PR): web-turn threading, defaulting the
unattended/untrusted surfaces (idle/heartbeat/classification) to workspace-write
— deployment-dependent (bwrap) so not flipped blind — task.ts inheritance, and
routing shell.exec through the same confinement as run (MED, harmless today —
grep only reads, and reads are unbounded in every mode by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis
oratis force-pushed the claude/h2-sandbox-modes branch from 1e2d3e0 to f825ecd Compare August 14, 2026 10:25
@oratis
oratis changed the base branch from claude/h1-capability-seam to main August 14, 2026 10:25
@oratis

oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

Fix applied — the pin now enforces (rebased onto current main)

Per the decision to wire it rather than hold, the core ship-blocker (HIGH-1) is fixed and proven: the sandbox now actually enforces a pinned mode instead of re-reading process.env every call. typecheck clean; full suite 1630 pass / 0 fail.

What changed

  • ToolContext.sandboxMode is now a live field, and capsOf resolves the world from it — ctx.caps ?? defaultCapabilitiesFor(ctx.cwd, ctx.sandboxMode) — falling back to the env default only when nothing is pinned. This is the single point where a pin takes effect.
  • Enforcement test (seam.test.ts): a read-only pin refuses a write even when LISA_SANDBOX_MODE=danger-full-access — the pin wins over the env, and a bounded pin selects a different (sandboxed) fs than the unconfined default. This is exactly the guarantee that was silently false before.
  • CLI threads session.header.sandboxMode, so the mode is frozen at session creation and can't be widened mid-session by an env change.
  • Subagent + channel router carry sandboxMode, so a subagent can't escape its caller's confinement and an operator exposing fs/shell to a channel (unsafeFullTools) can pin a bounded mode.

Deliberately deferred (documented, so this doesn't ship a false promise):

  • HIGH-2 default-flip — defaulting the unattended/untrusted surfaces (idle, heartbeat, feed/mail classification) to workspace-write. The plumbing is now in place, but bounded modes fail closed without an OS sandbox (bwrap on Linux / Seatbelt on macOS), so flipping the default blind could break autonomy/channels on a host without bwrap. Should land once the deployment's sandbox support is confirmed. The env-level LISA_SANDBOX_MODE already confines everything globally in the meantime.
  • Web-turn threading (server.ts — thread chat.session.header.sandboxMode) and task.ts inheritance (its execute has no ctx; needs deps to carry the mode).
  • MED-3 — routing shell.exec through the same confinement as run. Harmless today (only grep uses exec, and reads are unbounded in every mode by design); the correct fix needs an argv-aware wrapForSandbox.

Net: the sandbox genuinely enforces a set/pinned mode now (tested), which resolves the "recorded but never enforced" ship-blocker. The remaining items are safe, well-scoped follow-ups rather than false-security.

@oratis
oratis merged commit 560c498 into main Aug 14, 2026
2 checks passed
@oratis
oratis deleted the claude/h2-sandbox-modes branch August 14, 2026 10:26
oratis added a commit that referenced this pull request Aug 14, 2026
…finement, pin propagation (#362)

Closes the follow-ups flagged when H2 (#359) merged:

- HIGH-2: untrusted/unattended surfaces (channels, idle, heartbeat) now default
  to a confined `untrustedSurfaceMode()` instead of inheriting the local user's
  `danger-full-access` — capped at workspace-write where the host can enforce it
  (macOS Seatbelt / Linux bwrap), honouring a stricter env pin. Where no OS
  sandbox exists, bounded modes would fail closed and silently break autonomy,
  so it keeps the env default and warns once (loud "install bwrap" > broken
  heartbeat); an operator can force fail-closed everywhere with LISA_SANDBOX_MODE.
- MED-3: `shell.exec` (argv form) now routes through `wrapArgvForSandbox`, the
  same fail-closed confinement as `run` — so ONE mode governs both shell halves.
  Previously grep/exec ran unconfined even under read-only/workspace-write.
- Pin propagation: the web turn threads `chat.session.header.sandboxMode`
  (concurrent web sessions confine independently), and `task` (dispatched
  subagent) inherits `ctx.sandboxMode` so it can't escape its caller's sandbox.

Tests: untrusted-surface mode capping (host-independent), argv pass-through when
unconfined, and fail-closed/confine of a bounded argv.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant