Skip to content

fix(sec): untrusted text cannot drive the operator's terminal - #9

Merged
jlgore merged 1 commit into
013-attenuation-inheritancefrom
014-terminal-safety
Jul 26, 2026
Merged

fix(sec): untrusted text cannot drive the operator's terminal#9
jlgore merged 1 commit into
013-attenuation-inheritancefrom
014-terminal-safety

Conversation

@jlgore

@jlgore jlgore commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes triage f040 and f046 (absorbing f047). Stacked on #8 — base retargets to main once that merges.

The hole

A terminal reads text as a command language, and bee printed attacker-reachable text into it verbatim:

Sequence Effect
ESC [ 2 J clears the screen — erases what just happened
ESC ] 0 ; … BEL retitles the window
\r rewrites the line already printed (turn DENIED /etc/shadow into anything)
U+202E reorders characters after they're drawn (Trojan Source)

Sources: model prose, tool results, the kernel's DENIED audit target, and skill frontmatter. The most valuable thing to forge is the y/N capability prompt — the last thing between a project-supplied skill and a granted capability (f046).

The fix

src/safe_text.rs escapes C0 controls + DEL, the C1 range, and bidi/invisible formatting characters into their textual Rust form (\x1b, \u{202e}). Clean text is returned borrowed, so the streaming path allocates nothing. Two entry points: safe_block keeps \n/\t for prose; safe_line escapes those too, so untrusted text interpolated into a composed line can't start a line of its own and impersonate the harness.

Applied where untrusted text enters a front-end, before any styling — sanitize the payload, then paint it, never the reverse, or the escaping would eat bee's own SGR codes:

  • repl::terminal — every text method of the ReplOutput impl, including the DENIED audit line whose target the model chooses.
  • tui::app::handle_session — ratatui stores each grapheme as a cell symbol and flushes it, so the alternate screen is no shield. The triage didn't name the TUI, but it shares the exposure exactly; fixing one front-end and not the other would be half a fix.
  • app::repl::prompt_consent — the consent boundary itself escapes what it prints rather than trusting an upstream check, because it takes a GrantRequest from any source.

Skill frontmatter fails closed rather than being escaped: a name, description, required tool, or filesystem path carrying a control or bidi character is refused at load. Metadata is identity, it's short, and it's what the consent prompt quotes — no legitimate skill needs a character that moves a cursor. The body stays as authored; it's markdown, escaped where it's displayed.

Verification

  • safe_text unit tests, including a sweep asserting no dangerous character survives over U+0000..U+2100.
  • repl::terminal: an attack payload through all seven text methods, asserting no control character reaches the printer and the text isn't silently dropped; plus a dedicated test for a forged DENIED line.
  • tui::app: the same payload through six session events, asserting nothing interpretable lands in the chat model.
  • skills: three forged-metadata shapes refused, asserted on the reason (a YAML parse error would also keep them out and would prove nothing); and a control character in the body still loads.
  • cargo test --workspace --no-fail-fast and the tui/mcp variants: green except repl_command::no_provider_at_all_reports_what_is_missing, which fails identically on main (build without --features enforce) — pre-existing, unrelated.
  • cargo clippy --workspace --all-targets -- -D warnings, also with --features "tui mcp"; cargo fmt applied.

Also: docs/design-system.md gains this as principle 6 plus a checklist line, and docs/NEXT-read-write-modes-PROMPT.md is deleted — it briefs work that landed some time ago (read/write mode enforcement in crates/ebpf/src/main.rs, the fail-closed BTF offset guard in crates/userspace/src/kbtf.rs).

🤖 Generated with Claude Code

A terminal reads text as a command language. Model prose, tool output, kernel
audit targets, and skill metadata are all attacker-reachable, and every one of
them reached the screen verbatim: `ESC [ 2 J` clears it, `ESC ] 0 ; … BEL`
retitles the window, `\r` rewrites the line just printed, and a bidi override
reorders characters after they are drawn. The most valuable thing to forge that
way is the y/N capability prompt, which is the last thing standing between a
project-supplied skill and a granted capability (f040, f046, absorbing f047).

`safe_text` escapes control characters, C1 introducers, and bidi/invisible
formatting characters into their textual Rust form (`\x1b`, `\u{202e}`), leaving
clean text borrowed rather than copied. `safe_block` keeps `\n`/`\t` for prose;
`safe_line` escapes those too, so untrusted text interpolated into a composed
line cannot start a line of its own and impersonate the harness.

It is applied where untrusted text *enters* a front-end, before any styling —
sanitize the payload, then paint it, never the reverse, or the escaping would
eat bee's own color:

* `repl::terminal` — every text method of the `ReplOutput` impl, including the
  DENIED audit line, whose target the model chooses.
* `tui::app::handle_session` — ratatui stores each grapheme as a cell symbol and
  flushes it, so the alternate screen is no shield. The triage did not name the
  TUI, but it shares the exposure exactly.
* `app::repl::prompt_consent` — the consent boundary itself, which escapes what
  it prints rather than trusting an upstream check.

Skill frontmatter goes further and fails closed: a `name`, `description`,
required tool, or filesystem path carrying a control character is refused at
load. Metadata is identity, it is short, and it is what the consent prompt
quotes; no legitimate skill needs a character that moves a cursor. The body is
markdown and stays as authored — it is escaped where it is displayed.

Also deletes docs/NEXT-read-write-modes-PROMPT.md, which briefs work that landed
some time ago (read/write mode enforcement in crates/ebpf/src/main.rs, the
fail-closed BTF offset guard in crates/userspace/src/kbtf.rs).

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