ADR 0003: Tiered authority¶
- Status: Accepted
- Date: 2026-05-19
Context¶
With no internal sandbox (ADR 0002), the model sits inside the execution loop. A bash script cannot assess "this restarts a service users depend on, ask first"; a reasoning layer can. We want that judgement captured and, when desired, enforced - without crippling the default single-owner workflow.
Decision¶
Classify every call into a tier and admit it according to a configured mode.
| Tier | Meaning | Examples |
|---|---|---|
| 0 | Read-only / observe | server_info, ssh_hosts, ssh_check, session_recv |
| 1 | Reversible / low blast | ls, open a PTY, send keystrokes |
| 2 | Stateful / visible impact | service restart, package install, SFTP, forward |
| 3 | Irreversible / high blast | rm -rf, mkfs, shutdown, drop database, force-push |
Classification is conservative (ambiguity rounds up) and based on the tool plus a scan of the command/script text.
Modes (RELAY_SHELL_POLICY_MODE):
open- permit all, still classify and audit. Default; matches the documented single-owner posture.guarded- refuse Tier >= 2 unlessRELAY_SHELL_POLICY_ALLOWmatches; lower tiers pass.readonly- permit only Tier 0.
RELAY_SHELL_POLICY_DENY is evaluated first in every mode, including open, so
an absolute prohibition always holds. A refusal is audited (with the tier and
reason) and returned as a [DENIED ...] string, never an exception.
Consequences¶
- The tier is always recorded, so the audit trail shows intent and blast radius even when nothing is enforced.
- An operator can tighten a deployment (observation-only client, change allowlist) without code changes.
- Classification is heuristic and advisory in
openmode: defence in depth, not a sandbox. It complements, and never replaces, the deployment controls in ADR 0002. The regex sets are intentionally simple and auditable; extend them per deployment via the deny/allow lists rather than by widening the built-in heuristics silently.
Rejected¶
- No classification (rely only on deployment controls): loses the intent record and the ability to tighten without a rebuild.
- A full capability/command allowlist as the primary control: unbounded for
general administration; high friction for low risk reduction on a
single-owner host. The deny list plus
guardedallow list covers the cases that matter without that cost.
2026-10-14 update: classifier tuning (A14-C9, PATTERNS_VERSION 16)¶
The 2026-10-14 audit corpus showed the heuristics drifting in both directions; the
reproduced cases are fixed. The classifier is still not a boundary: a permitted shell
command is not contained by it, and guarded/readonly remain advisory for anything the
patterns do not name.
- Under-classified, now escalated. Tier 3:
rm \<newline>-rf(line continuations are removed before matching, as the shell does),sudo -u root reboot,nice -n 10 reboot,timeout 5 reboot(wrappers with arguments),redis-cli FLUSHALL|FLUSHDB. Tier 2:systemctl start|enable|reload|...(including with--user/-H host),service ... start,useradd/usermod/groupadd/chpasswd/...,iptables -A/-I/-D/...,nft add/ delete/...,firewall-cmd --add/--permanent/...,kubectl exec|drain|cordon|patch|edit| create|...after global flags,redis-cli shutdown|config set,docker-compose, and a>/>>/teeinto~/.ssh/,authorized_keys, a shell rc file,sudoers, /root, /boot, /usr, /bin, /sbin, /lib*, /opt, /srv, /sys or /var/{spool,lib,www}. Account, firewall and cache commands match in command position only, soman useradddoes not. - Over-classified, now lower.
crontab -l,docker compose ps|logs|config|..., and a privilege wrapper named by a read-only command (grep sudo /var/log/auth.log,man sudo,echo sudo). Other segments of the same line are still scanned, sogrep x f | sudo tee ystays Tier 2, and a line that can evaluate text (echo 'sudo id' | sh,eval,$(...)) keeps the privilege tier. - Verification. Every case has a positive and a near-miss test
(
tests/test_classifier_v16.py); a differential run over a realistic corpus and 60,000 random commands against v15 showed no Tier 3 downgrade except where a line continuation joins the lines (echo hi \<newline>rebootis one command whose argument isreboot). - Still heuristic. Quoting,
eval, variable indirection,sh -cstrings, scripts, and commands not named above remain Tier 1 (see the limits below).
STPA trace for the 2026-10-14 tuning (authority and automated decision)¶
The tier decides whether guarded and readonly refuse a call and whether the opt-in
Tier-3 broker (ADR 0009) demands a second step, so moving a command between tiers moves
authority. Boundary: policy.classify, the policy modes, and the audit record of the
decision; the shell and the remote host are outside it. Controller: the classifier (an
automated decision); human intervention: the operator's deny/allow lists and the audit
review.
- Losses. L-1 A destructive or persistent change runs without the refusal or the second step the operator configured. L-2 A harmless read is refused, so the operator loosens the mode (or stops using it) and the control weakens. L-3 The audit record hides what ran.
- Hazards. H-1 A command that changes state is admitted as Tier 1 (under-classified). H-2 A read-only command is refused as Tier 2/3 (over-classified). H-3 The text the classifier sees differs from what the shell executes (line continuations, wrappers).
- Constraints. SC-1 (H-1) A named state-changing command is Tier 2 or 3 wherever the shell would run it. SC-2 (H-2) The same word as an argument or text of a read-only command does not escalate. SC-3 (H-3) The classifier reads the command as the shell does for the cases it models: continuations outside single quotes are removed, and wrappers with arguments are skipped only at a command boundary. SC-4 Every pattern is linear, because classification runs before admission on the event loop.
- Unsafe control actions. UCA-1 (not provided) a state change is not escalated:
rm \<nl>-rf,sudo -u root reboot,nice -n 10 reboot,systemctl start,iptables -A,kubectl exec,redis-cli FLUSHALL, a redirect intoauthorized_keys. UCA-2 (provided unsafely) a read is escalated:crontab -l,docker compose ps,grep sudo auth.log,kubectl get pod delete,systemctl --user status stop,firewall-cmd --permanent --get-zones,echo timeout 5 reboot,grep redis-cli FLUSHDB. UCA-3 (wrong order) a continuation is removed after matching, or inside single quotes where it is literal text. UCA-4 (too late) a quadratic pattern delays admission. - Causal scenarios. CS-1 (UCA-1) a model or attacker splits or wraps the command so
the token the pattern needs is not adjacent. CS-2 (UCA-2) a verb-shaped word is an option
value or a resource name (
--profile logs,get pod delete), so a pattern that scans for the verb anywhere misreads it; the patterns therefore model option arity forsystemctl,kubectlanddocker composeand treat a flag value as a value. CS-3 (UCA-3) a literal\<newline>insideprintf '...'; the scan keeps single-quoted spans. CS-4 (UCA-4) option runs repeated thousands of times; every repetition is bounded. CS-5 (UCA-1) a read-only command's output is executed:echo 'sudo -n touch /x' | sh,$(echo sudo ...),eval. Namingsudoafterechois data only if nothing evaluates the text, so the reader exemption is switched off for any line with an interpreter orxargsafter a pipe,eval/source/exec, ash -cstring, a backtick,$(or<((test_a_real_privilege_wrapper_still_escalates). - Trace. SC-1 and UCA-1:
test_wrapped_or_continued_destructive_commands_are_tier3,test_state_changes_that_were_tier1_are_now_tier2,test_wrappers_with_arguments_need_a_command_boundary. SC-2 and UCA-2:test_near_misses_stay_tier1,test_read_only_commands_no_longer_over_classify,test_a_real_privilege_wrapper_still_escalates. SC-3:test_line_continuation_is_removed_before_matching. SC-4:test_new_patterns_are_linear_on_adversarial_inputandpytest -m fuzz. All intests/test_classifier_v16.py. - Assumptions and limits. An unbalanced quote makes the continuation scan fall back to
removing every continuation (the conservative direction). Here-document bodies are not
tracked. Quoting,
eval, variable indirection,sh -cstrings, scripts and unnamed commands stay Tier 1; wrappers chained behind other wrappers (sudo nice reboot) are not followed. The deny-list probe reads the raw text. This is a heuristic with an operator override, not a boundary; the no-sandbox posture (ADR 0002) is unchanged.
Addendum 2026-10-14: the tier now has a stated basis and reaches the caller¶
policy.assess() returns the tier with the reason it was chosen (BASIS_* strings), and
PolicyDecision.basis carries it to the runner. In open mode (the default, which refuses
nothing) the tier and basis now also reach the caller as a trailing [NOTICE ...] line for
Tier 2 and Tier 3 calls. Admission rules are unchanged. See
ADR 0013.