Skip to content

fix(sec): land the stranded terminal-safety and fail-closed-hooks fixes - #11

Merged
jlgore merged 2 commits into
mainfrom
015-hooks-fail-closed
Jul 26, 2026
Merged

fix(sec): land the stranded terminal-safety and fail-closed-hooks fixes#11
jlgore merged 2 commits into
mainfrom
015-hooks-fail-closed

Conversation

@jlgore

@jlgore jlgore commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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

  • `a1c713f` fix(sec): untrusted text cannot drive the operator's terminal (f040, f046, +f047) — untrusted text is escaped where it enters a front-end (`safe_text`, applied in `repl::terminal` and `tui::app::handle_session`) and only then styled; the consent prompt escapes every field it prints; skill frontmatter carrying control or bidi characters is refused at load.
  • `a32e156` fix(sec): a hook that cannot evaluate an operation refuses it (f007, f034) — a `bpf_d_path` failure, a null struct argument, an unavailable scratch slot, and any non-IP address family are denied and audited instead of allowed. Research R16; VM cases `exec-unresolvable-denied`, `file-unresolvable-denied`, `net-unix-denied` (35/35).

Verification

  • Merge into `main` is conflict-free.
  • `cargo test --workspace` passes apart from `repl_command::no_provider_at_all_reports_what_is_missing`, which fails identically on `origin/main` — pre-existing, not introduced here.
  • eBPF changes were VM-verified on the enforcement matrix (35/35) before their original merge.

🤖 Generated with Claude Code

jlgore and others added 2 commits July 25, 2026 20:34
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>
@jlgore
jlgore merged commit 9c123f6 into main Jul 26, 2026
4 checks passed
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