Skip to content

Badge terminal rows that opt out of the sandbox - #3038

Open
andrewkchan wants to merge 1 commit into
get-bb:mainfrom
andrewkchan:sandbox-escape-badge
Open

Badge terminal rows that opt out of the sandbox#3038
andrewkchan wants to merge 1 commit into
get-bb:mainfrom
andrewkchan:sandbox-escape-badge

Conversation

@andrewkchan

@andrewkchan andrewkchan commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Human comments

Adds a red "unlocked" badge next to claude code terminal commands that run outside of sandbox:
Screenshot 2026-09-03 at 5 14 35 PM

What was wrong

Claude Code's Bash tool accepts a dangerouslyDisableSandbox flag, and bb turns the SDK sandbox on itself for workspace-scoped acceptEdits/auto sessions (buildWorkspaceWriteSandbox). The timeline rendered a command that ran sandboxed and one that deliberately escaped identically, so the security-relevant call was indistinguishable from its neighbours. parseClaudeBashCommand read run_in_background off the tool input and dropped the sandbox flag next to it, so the signal never left the provider bridge.

While verifying the fix in the dev app I found a second, pre-existing bug: the commandExecution branch of parseExecLifecycleEvent never copied item.presentation onto the projected call, unlike the delegation (exec-lifecycle.ts:222) and tool-call (:294) branches. Every command row reached the UI with presentation: undefined. Nothing had consumed it before — mapExecutionTitle skips the presentation path for commands — so the badge was persisted correctly on the event and then silently dropped in projection.

What changed

Sandbox signal (plugins/provider-claude-code)parseClaudeBashCommand parses dangerouslyDisableSandbox alongside run_in_background; createClaudeDeltaTranslator takes a required sandboxEnabled, fed from sessionOptions.sandbox?.enabled in the bridge, so classifyClaudeToolUse can distinguish "opted out of an active sandbox" from "session never had one". Only the former gets a badge.

Contract (packages/domain/src/item-presentation.ts) — presentation grows an optional badge: glyph, a compact label, a hint for hover/screen-reader text, and tone. Available to any provider bridge, not just this one.

Rendering (packages/thread-view, apps/app) — a new badge title decoration derived from the row presentation, added in the command-title path, the single-exploration-intent path, and the compact multi-intent path (first line only, so a compound command doesn't repeat it). The app renders it as a destructive-toned icon with title/aria-label set to the hint; the CLI gets (sandbox off) for free through title.plain. Bash-driven grep/ls rows are command rows with parsed intents, so terminal, search, and list rows are all covered by one change.

Projection fix (packages/thread-view/src/exec-lifecycle.ts) — one line copying item.presentation onto command calls, mirroring the sibling branches.

Wire change: HOST_DAEMON_PROTOCOL_VERSION 173 → 174. The bridge artifact is served by the server, so a new bridge can run under an old daemon. That daemon parses each thread/delta with its own compiled copy of the schema (bridge-protocol-adapter.ts:513), and since presentation is a non-strict z.object it strips unknown keys rather than rejecting — verified: an old shape accepts the new payload and badge comes back null. Without the bump the badge would be silently swallowed before the event is persisted, with no error anywhere.

No CLI command, flag, or config knob changed. Mobile renders none of the title decorations, so the badge is web/CLI only — the same gap docs/api_to_audit.md:1435 already tracks for the presentation base.

How you verified

Tests added:

  • plugins/provider-claude-code/src/presentation.test.ts — badge present/absent across the three input × session-sandbox combinations.
  • packages/thread-view/test/timeline-row-title.test.ts — the decoration reaches both title paths, and only the first compact intent line.
  • packages/thread-view/test/v3-item-projection.test.ts — a commandExecution item with a badge presentation driven through the real projection, asserting both row.presentation and (sandbox off) in the rendered title. Confirmed this fails with the exec-lifecycle.ts fix reverted and passes with it.
  • apps/app/.../ThreadTimelineRows.presentation.test.tsx — the icon renders on both a command row and a search row, with the hint on title and aria-label, and not on an unbadged row.

Commands run:

  • pnpm exec turbo run typecheck — 80/80 packages pass.
  • pnpm exec turbo run test --filter=@bb/thread-view --filter=bb-plugin-provider-claude-code --force — 370 and 337 tests pass.
  • pnpm exec turbo run test --filter=@bb/app --force — 3507 pass, 3 skipped.
  • oxfmt --check clean on all 16 changed files.

Manual check in the dev desktop app: ran the same command with and without the sandbox, confirmed via the dev SQLite DB that the badge is persisted on the escaped call only.

AGENT GENERATED

Claude Code's Bash tool takes a dangerouslyDisableSandbox flag, and bb
enables the SDK sandbox itself for workspace-scoped acceptEdits/auto
sessions. The timeline showed no difference between a command that ran
sandboxed and one that escaped, so an unsandboxed call was
indistinguishable from its neighbours.

parseClaudeBashCommand now reads the flag alongside run_in_background,
and the delta translator carries the session's sandbox state so
commandPresentation can tell "opted out of an active sandbox" from
"session never had one". Only the former gets a badge.

Presentation grows an optional badge (glyph, compact label, hover hint,
tone), rendered by the timeline title layer as a new decoration: an icon
in the app, "(sandbox off)" in the CLI. Bash-driven grep/ls rows are
command rows with parsed intents, so terminal, search, and list rows are
all covered.

Fixes a pre-existing projection bug found while verifying this: the
commandExecution branch of parseExecLifecycleEvent never copied
item.presentation onto the projected call, unlike the delegation and
tool-call branches. Every command row reached the UI with no
presentation. Nothing consumed it before, since command titles skip the
presentation path, so the badge was persisted correctly and then
dropped in projection.

HOST_DAEMON_PROTOCOL_VERSION 173 -> 174. The bridge artifact is served
by the server, so a new bridge can run under an old daemon; that daemon
parses each thread/delta with its own copy of the schema, and since
presentation is a non-strict z.object it would silently strip the badge
before posting the event the server persists.

Co-Authored-By: Claude Opus 5 (1M context) <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