TacidOS
Developers
Documentation

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

Status
accepted
Date
2026-09-07

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 su names su, which exists to become another user. That is refused wherever it appears in a command, not only first: env sudo … is the same request as sudo ….

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::PrivilegeEscalation carries the escalator it found alongside the command, so the explanation can name it. Invariant::Uninspectable is new.
  • grep -c warning build.log still runs: the rule is an interpreter and a program flag, not either alone. sh /etc/tacid/hooks/report.sh still 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 -c is caught by the basename; a copy of sh installed 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 ExecHost is carried out, the operation should take a program and its arguments as separate typed fields rather than an argv, 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.