Decision 0066
0066. A command no rule can read is not one any autonomy level reaches
Architecture decision record 0066: 0066. A command no rule can read is not one any autonomy level reaches
Context
Root is never represented as arbitrary shell is one of the hard invariants
(ADR-0010). It is enforced in
Engine::invariants, which runs before anything else and which no autonomy level reaches: at
sovereign, evaluate allows anything within the invariants, so a hole here is a hole at every
level at once.
The enforcement read argv[0] and nothing else. ["sudo", "useradd", "x"] was refused;
["env", "sudo", "useradd", "x"] was not, and neither was ["sh", "-c", "sudo useradd x"]. The
narrow read answered a narrower question than the invariant asks — is this literally an escalator
invocation? rather than does this ask to become root?
A model can put either form on the wire. Completion::Act carries Vec<ActionItem>, and
ExecHost { argv } is inside the closed set a model may answer with. The GBNF grammar of
ADR-0056 constrains
a local engine; a cloud model’s JSON is parsed straight into Completion.
What stops it today is that nothing carries it out. Keeper::act handles FileWrite,
GitCommit and GitDiscard, and every other item falls to a deliberate arm that says nothing on
this machine carries out … yet; the transaction was authorised and is not being performed, and
saying so is the point. An escalation is impossible today because the execution path does not
exist — defence by absence, stated in the code rather than relied on silently.
Which is exactly why it is fixed now. The day that arm gains an ExecHost case, a hard invariant
has a hole a shell wrapper walks through, and nothing about that day will look like a security
change.
Decision
Two rules, each honest about the question it answers.
An escalator is refused wherever in the command it stands. Every element’s basename is
checked against ESCALATORS, not only argv[0]. env sudo …, nice … doas …, xargs sudo …
and systemd-run --pipe pkexec … are the same request wearing a hat. This applies to
ExecSandbox as well as ExecHost: the sandbox is a mitigation, not a licence.
The rule is blind to position and therefore over-refuses — echo su is refused too. That costs
nothing, because nothing needs an untyped host command to print the word, and the explanation
states the rule rather than guessing at intent:
echo sunamessu, which exists to become another user. That is refused wherever it appears in a command, not only first:env sudo …is the same request assudo ….
A host command handed to an interpreter as text is refused for being unreadable. sh -c "sudo useradd x" puts the escalation inside one string element, where scanning elements cannot see it
and only a shell parser could. Writing a shell parser to decide what may run is how this class of
hole is made, so the string is refused rather than parsed.
This is a separate invariant — Uninspectable, not PrivilegeEscalation — because
sh -c "echo hi" is not privilege escalation, and an explanation that called it that would be a
lie told to a person trying to understand a refusal. The honest reason is broader and simpler: an
invariant’s job is to read what will run before it runs, and a command it cannot read is not one
any autonomy level reaches.
The interpreter rule is for the host, and stops there. A sandboxed command may be a shell
script; that is much of what a sandbox is for. Inside bubblewrap, under a seccomp filter, as a
principal with no privilege, sh -c "cargo test" is the thing rather than the danger. Outside the
sandbox there is no second line.
Consequences
Invariant::PrivilegeEscalationcarries the escalator it found alongside the command, so the explanation can name it.Invariant::Uninspectableis new.grep -c warning build.logstill runs: the rule is an interpreter and a program flag, not either alone.sh /etc/tacid/hooks/report.shstill runs too — it names a file, which is at least a thing on disk with a name, rather than a string that exists only in the request.- The interpreter list is a deny list, and deny lists leak.
busybox sh -cis caught by the basename; a copy ofshinstalled under another name is not. The list is a second line, and this ADR says so rather than implying completeness. - The right fix is upstream of this rule. The day
ExecHostis carried out, the operation should take a program and its arguments as separate typed fields rather than anargv, so that “which program runs” is a field rather than a convention about element zero. This ADR’s rules should stop being needed then. Recorded here so that whoever writes that arm finds this first.
Alternatives rejected
Parse the -c string and scan what is inside it. It would catch sh -c "sudo useradd x"
precisely, and miss sh -c "s''u''do useradd x", sh -c "$(echo c3Vkbw== | base64 -d) useradd x",
and every other quoting the shell supports and the parser does not. A partial shell parser inside
a security boundary is worse than no parser, because it produces an allow that looks reasoned.
Refuse any argv element containing whitespace. Simple and far too broad:
--message=hello world is an ordinary argument. It would also still miss
sh -c "sudo\tuseradd".
Refuse a bare -c or -e element regardless of the program. grep -c, tar -c, sort -c
and ps -e are ordinary commands. A rule that refuses them looks arbitrary, and a security rule
that looks arbitrary gets worked around rather than understood.
Leave it until ExecHost is carried out. The change is small and provable now, and the change
that opens the hole will be a feature commit with no security in its diff and no reviewer looking
for one.