Enumerate a Value's Untrusted Surfaces, Not Its Fields — and Guard by Construction
Problem¶
claude-boot adopt prints a block that the workflow tells an agent to relay verbatim and the
owner to run unchanged. Untrusted bytes reaching that block are therefore one copy-paste away
from a shell.
Four review passes each found a NEW injection site in the same module, each on a field the previous fix had not covered:
| Pass | Field | Surface |
|---|---|---|
| 1 | cliSessionId |
a JSON field value |
| 1 | title, question headers |
JSON field values |
| 3 | the entry's file name | the basename on disk |
| 4 | account / org |
the directory names |
Every individual fix was correct. The process was still wrong, because each one answered "is this field safe?" when the question that terminates is "what are the untrusted surfaces?"
Root cause¶
Two compounding mistakes.
1. The object was never surface-enumerated. A DesktopEntry is read from
<store>/<account>/<org>/local_<id>.json. That path yields three independent
attacker-authored surfaces:
- the JSON field values,
- the file basename,
- the two directory names the record's
accountandorgare derived from.
Passes 1–3 hardened surfaces 1 and 2, one field at a time. Surface 3 was never listed, so nothing in the process could have found it except another bug report.
2. A shell guard was mistaken for a display guard. The command line used
shlex.quote(account). That is correct for the shell: it wraps the value so the shell reads one
word. It does not flatten anything — newlines and ESC[2K survive byte-for-byte inside the
quotes, and the terminal renders them. A hostile directory name therefore printed a standalone
runnable line inside a genuine command.
The guard-to-case map recorded the error in writing: "shlex.quote on the command line — NONE,
unreachable while the shape check holds." True for the ids on that line. False for
--account/--org on the same line.
Resolution¶
Enumerate surfaces, then guard by construction.
- List the surfaces of the value, not the fields of the struct. "Where do the bytes of this object come from?" is answerable and finite; "is field X safe?" is neither.
- Make the guard structural. Every output line now goes through one formatter that flattens each interpolated argument. A new print cannot forget the guard because there is nothing to remember. Mutation evidence: disabling the flattening turns seven assertions red across three cases, where per-field guards turned one or two.
- Refuse rather than mangle where the output must stay executable. Flattening a command produces a command that differs from the one that works. For a target that cannot be named safely, print no command and fall through to an already-sanitized list.
- Layer deliberately, and prove each layer separately. Where two locks exist, find an input each catches alone — otherwise both survive mutation individually and the "two locks" claim in the docstring is unbacked. That happened here and was only caught by mutating each half.
Prevention¶
- When a review finds an injection site, do not fix the field. Enumerate the surfaces first and ask which others share the provenance. If the answer is "I have not listed them", the next pass will find the one you missed.
- Treat "N passes each found one more instance" as a signal about the method, not the code. A fourth patch is evidence the approach is wrong.
- Name what a guard actually guarantees.
shlex.quoteis shell quoting,_safe()is display flattening, a shape check is admission control. They are not interchangeable, and a note claiming one covers the other is a bug with a long fuse. - Prefer one chokepoint over N disciplined call sites. Coverage that depends on memory decays at exactly the rate new call sites are added.
Related¶
docs/solutions/patterns/security-fixtures-must-prove-reachability.md— the testing half of the same review, and the reason these findings were trustworthy at all.- SPEC-203
intake/pending/spec-203-followup-adopt-write-path-hardening.md— the surviving lower-severity items, and the sweep that produced the surface enumeration.