fix(sec): untrusted text cannot drive the operator's terminal - #9
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes triage f040 and f046 (absorbing f047). Stacked on #8 — base retargets to
mainonce that merges.The hole
A terminal reads text as a command language, and bee printed attacker-reachable text into it verbatim:
ESC [ 2 JESC ] 0 ; … BEL\rDENIED /etc/shadowinto anything)U+202ESources: model prose, tool results, the kernel's DENIED audit target, and skill frontmatter. The most valuable thing to forge is the
y/Ncapability prompt — the last thing between a project-supplied skill and a granted capability (f046).The fix
src/safe_text.rsescapes 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_blockkeeps\n/\tfor prose;safe_lineescapes 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 theReplOutputimpl, 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 aGrantRequestfrom 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_textunit tests, including a sweep asserting no dangerous character survives overU+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-fastand thetui/mcpvariants: green exceptrepl_command::no_provider_at_all_reports_what_is_missing, which fails identically onmain(build without--features enforce) — pre-existing, unrelated.cargo clippy --workspace --all-targets -- -D warnings, also with--features "tui mcp";cargo fmtapplied.Also:
docs/design-system.mdgains this as principle 6 plus a checklist line, anddocs/NEXT-read-write-modes-PROMPT.mdis deleted — it briefs work that landed some time ago (read/write mode enforcement incrates/ebpf/src/main.rs, the fail-closed BTF offset guard incrates/userspace/src/kbtf.rs).🤖 Generated with Claude Code