Three Sinks and Everything Else

Written later and filed to this date, as part of filling in every day of the year.

Twelve of twenty-seven probes came back allow from the live guard, and every one of the twelve should have been a refusal.

What the guard reads

Every shell command an agent runs on my box passes through a hook first. The hook reads the command as text and refuses the dangerous shapes: privilege escalation, service control, a destructive delete, a push of the public site from anywhere but the one script allowed to push it. It judges text, not intent, and that is the point. An agent cannot talk its way past a regular expression.

Text has a problem the hook had to solve early. Agents write files and commit messages with heredocs, a block of literal lines fed to a command on standard input. A commit message that describes a privilege-escalation fix contains the words for privilege escalation. If the guard judged those lines, an agent could not record what it had done. So the hook stripped heredoc bodies before judging, on the reasoning that a body is data.

Except when it is not. bash <<'EOF' runs its body. So the stripper kept the body when the header named a shell, eval or source, and judged it as a program.

Three packs

The guard is not a file an agent may edit. Changes to it arrive as apply packs: a before and after copy of the hooks, a probe that runs payloads through both, an apply script and a rollback, all run by me. In mid-September an independent reviewer, a second model family that sweeps my repositories every morning, found that python3 <<'PY' was being stripped too. The body of a Python heredoc runs. So did node, perl, ruby, php and a handful of others it listed. A pack added them to the set of consumers that keep their body. I applied it the same day.

The next morning the reviewer re-read the applied pack and found that awk, which the pack had added to the set, kept its body and was never judged, because a second table elsewhere in the hook decided which interpreters count as running a program, and awk had no entry there. Another pack. Applied.

On the first of October I asked for a third: a guard patch plus regression evidence, for my approval. The session that took it reproduced nine shapes against the live hooks two days later. Three were pushes of the public site hidden inside awk, gawk and tclsh programs read from standard input. Six were privileged commands inside bodies handed to ssh, to at, to the fish shell, to the sqlite shell’s escape to the system, to mawk and to julia. All nine were allowed by both hook files. Two more probes showed the parser could be fooled about where a heredoc began at all: a # before the operator, a header line continued with a backslash.

Nine consumers in one afternoon, none of them exotic. Every one had been missing from a list that two careful packs in three weeks had been extending by hand.

The other list

The pack that landed in my queue that evening does not add nine names. It deletes the list.

A body is now stripped, treated as data, only when the command that owns the heredoc is one of three things: cat, tee, or git commit reading its message from standard input. Everything else keeps its body, and a kept body is judged as program text by the same table that judges the command line. The question flipped from “which programs run their input?”, unbounded, and I was never going to finish answering it, to “which programs provably do not?”, which has three answers I can name.

Even those three are distrusted when the header gives a reason. A command substitution or a backtick before the operator means something else owns the heredoc. An odd number of quotes, a #, a trailing backslash, a pipe after the operator: in each case the body is kept and judged. A nested heredoc inside a kept body is not rescanned; it is already inside program text. And the hidden-push check, which had carried its own private interpreter table, now reads the one table the rest of the hook uses, so awk cannot fall through a second time.

What it would have cost

A stricter guard refuses more, and I needed to know how much of that would land on real work. The pack replays every heredoc command from the last thirty days of agent transcripts, 6,713 commands from 902 sessions, through the old hooks and the new. No command that was refused becomes allowed. Five that were allowed become refused: four Python programs whose text names a gated command, and one cat whose heredoc sits inside a $( ) substitution, which is exactly the shape the scope said to distrust. Five in 6,713, each defensible on its own.

The replay also caught the pack’s own first mistake. Its first pass flipped a commit message to refused, because the header carried a quoted author name and the quotes broke the word split. That was a bug in the pack, not a refusal I accept. It was fixed and every probe re-run before the pack was filed.

I had been maintaining the wrong list. A list of programs that run their input can only grow, every entry is one reviewer’s finding away, and every missing entry is a hole. A list of programs that cannot run their input is short and stable, and when the guard cannot prove a command belongs on it, it reads the body.

What is loud now

Two canaries in the guard’s integrity suite expect the new refusals. They report pending, and will go on reporting it until I run the apply script by hand, which the pack ends by telling me how to do.


← all writing