fix(sec): land the stranded terminal-safety and fail-closed-hooks fixes - #11
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>
Four branches in the LSM programs returned 0 — allow — when the hook could not reach a decision: a `bpf_d_path` failure in `file_open` and `bprm_check_security`, a null `bprm`/`file`/sockaddr argument, an unavailable `PATHBUF` slot, and any address family `socket_connect` does not decode. Three are provokable from inside a scope, which makes them escapes rather than edge cases. `bpf_d_path` fails with -ENAMETOOLONG once the resolved path passes its 4KB buffer, and `execve` never has to pass a path that long: chdir down a deep chain and exec a short *relative* name, and the kernel resolves it to something the hook cannot render. That was a general way out of an exec-enforced scope — the allowlist simply stopped applying (f007). The family default was the same shape in the network hook: an AF_UNIX connect to a local agent socket left an egress-enforced scope unmediated (f034). `deny_unevaluated` now returns the denial errno and emits an audit record in all four. It runs only after the scope has been shown to enforce that dimension, so an unenforced scope is untouched and observe mode still records without blocking. For non-IP families this is a policy statement as much as a code change: the authoring language spells destinations `host:port`, so no rule can ever name an AF_UNIX or AF_NETLINK peer — and "no rule matches" in an enforcing scope means deny. The cost is real and accepted: a network-enforced scope refuses local socket IPC outright, and no enforced scope can open or exec a path longer than the kernel's path buffer. Recorded as research R16, with the inode-identity fallback named as the better long-term answer once the backend can pin inodes. VM matrix: three new cases (`exec-unresolvable-denied`, `file-unresolvable-denied`, `net-unix-denied`), 35/35 on the BPF-LSM VM. All three fail against the pre-fix binary — the exec one only after its descent loop was rewritten to use shell builtins, since a denied `seq` left it in the shallow directory and turned it into a false PASS. Also marks triage f010 as stale rather than open: `hardening.rs::drop_privileges` already sets PR_SET_NO_NEW_PRIVS and empties the capability bounding set except the DAC pair, and the `priv-drop` case asserts it. 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.
PRs #9 and #10 were stacked — #9 targeted `013-attenuation-inheritance` and #10 targeted `014-terminal-safety`. Both merged into their base branches rather than into `main`, so two verified security fixes never landed. This PR carries both to `main`.
Commits
Verification
🤖 Generated with Claude Code