ADR 0013: Open by default, with notices about what is unsafe¶
- Status: Accepted
- Date: 2026-10-14
Context¶
relay-shell is an operator tool: its job is to let a trusted MCP client run any command on
the local host and on SSH targets, with a tier classifier and an audit log beside it. The
policy layer (ADR 0003) has three modes. open admits everything and only classifies and
audits; guarded and readonly narrow the envelope. The shipped default was already
open, with an empty deny list and the Tier-3 confirmation broker (ADR 0009) off, but that
was a fact scattered over several documents, nothing pinned it, and the only feedback to a
caller about risk was a tier number in the audit log it may never read. An operator who
wants the server to "just run it" had to infer that, and a caller that ran rm -rf got
[exit 0] with no sign that the server had judged the command irreversible.
The request behind this ADR (2026-10-14, repository owner): no command is blocked by
default, relay-shell can do everything, and it tells the caller what is unsafe. Per
AGENTS.md a posture change needs explicit scope and design review; this ADR is that
record, and the request is the scope.
Decision¶
- Open is the contract, and it is tested. With the default configuration
(
RELAY_SHELL_POLICY_MODE=open, noRELAY_SHELL_POLICY_DENY, noRELAY_SHELL_POLICY_ALLOW,RELAY_SHELL_CONFIRM_TIER3off) the policy layer admits every command on every tool.tests/test_notices.pypins the defaults and runs a corpus of commands people consider dangerous (rm -rf /,mkfs,ddto a block device,shutdown,curl | sh,sudo ...) throughPolicy("open"). Refusal remains available and opt-in:guarded,readonly, a deny list, and the confirmation broker. The deny list is still enforced first in every mode (ADR 0003, unchanged). - The caller is told. A call that was admitted and ran as Tier 2 or Tier 3, or with
SSH host-key verification off, gets one or two trailing lines in its result:
[NOTICE tier 3 (IRREVERSIBLE), matched an irreversible-operation pattern. It may not be undoable and ran with no confirmation step; ... Classification is a heuristic, not a boundary].policy.assess()returns the tier together with the basis that produced it (BASIS_*strings), andPolicyDecision.basiscarries it to the runner. A tool whose result is a JSON object (ssh_fanout) carries the notices in a"notices"array inside the object, so the result still parses.ssh_checkandssh_fanoutresolve their targets inside the work, so they pass the hosts that would be dialled with host-key checking off (server default or inventory entry) as extra notices. Tier 0 and 1 calls get nothing unless they dial a host with verification off, so a notice keeps its meaning.RELAY_SHELL_UNSAFE_NOTICES=falseturns the lines off. - The audit record does not change. The record is written from the same
finaltext as before the notice is appended, sooutput_sha256andoutput_lendescribe the command's output, not the notice. The tier is already in the record. Nothing the notice says is evidence; it is a message to the caller. - The standing posture is reported.
server_infogainswarnings, a list built bynotices.config_warnings()from the settings (running as root, open policy without a deny list or confirmation,ssh_known_hostsofaccept-neworignore, per-call weaker host keys allowed, HTTP without OAuth, audit chain off). The same list is logged at startup and printed by--check-config. An empty list is not a security statement. - What still refuses a call, honestly listed. These are resource bounds and startup
checks, not policy, and each has a setting:
RELAY_SHELL_MAX_INPUT(a call whose command, stdin, script and env exceed it is refused rather than scanned partially, because the scan is synchronous),RELAY_SHELL_MAX_TIMEOUT(a command is killed at the clamped timeout; long work belongs inshell_spawn/ssh_spawn),RELAY_SHELL_MAX_SESSIONSandRELAY_SHELL_MAX_FORWARDS, the output cap (truncation, not refusal), and the startup refusal of an unauthenticated non-loopback HTTP listener unlessRELAY_SHELL_ALLOW_UNAUTH_NETWORK=true.
Rejected alternatives: no change (leaves the posture implicit and the caller uninformed);
a warning field in a structured result (the tools return text, and every client already
reads the tail of it; a new envelope would break clients that parse [exit N]);
a leading notice (breaks clients that match the [exit N] prefix); blocking by
default with a documented override (contradicts the request, and a classifier that is
heuristic would give a false sense of containment); notices on Tier 1 (a notice on
nearly every call would carry no information).
STPA analysis¶
Purpose, boundary, stakeholders¶
Purpose: a caller can run any command and knows, at the moment it runs, when the command or
the server's configuration carries risk the caller has not been told about. Boundary: the
runner (Relay.run), the policy layer, server_info and the startup path. The target
hosts, the MCP client's own judgement and the operator's backups are outside it.
Stakeholders: the operator, the MCP client (human or model), the owners of the target hosts.
Losses and hazards¶
- L-1 A destructive command runs without the caller or operator having recognised it as one.
- L-2 The operator believes a stricter mode is active when it is not (false assurance).
- L-3 A notice or warning is taken as a guarantee that unflagged commands are safe.
- H-1 An admitted Tier 2/3 call returns no indication of its classification.
- H-2
server_infodescribes a stricter posture than the one in force. - H-3 The classifier's verdict is presented as a containment boundary.
- H-4 The notice path changes what is audited, or can fail a call.
Safety constraints¶
- SC-1 (H-1) Every admitted call classified Tier 2 or above carries a notice unless the operator switched notices off.
- SC-2 (H-2)
warningsis computed from the liveSettings, never from a cached or documented value. - SC-3 (H-3, L-3) Every tier notice says the classification is a heuristic, and the
documentation states that a command without a notice is not thereby safe. (The SSH
host-key notices and the
warningsentries report settings, not a classification, and do not repeat it.) - SC-4 (H-4) The notice is appended after the audit record is written and cannot raise: it is a pure function of the decision and the already-redacted audit arguments.
- SC-5 (L-2) The default posture is pinned by a test, so a change to a default is a visible diff.
Control structure¶
Controller: the policy layer (admit or refuse). Controlled process: the executor that runs
the command. Control actions: admit, refuse, mint a confirmation token. New feedback path:
the notice to the MCP client, and warnings to the operator. Process model of the caller:
"the server refuses what it considers unsafe" is replaced by "the server runs what it is
asked and says what it considers unsafe".
Unsafe control actions¶
| ID | Type | Unsafe control action | Constraint |
|---|---|---|---|
| UCA-1 | Not provided | An admitted irreversible command returns no notice | SC-1 |
| UCA-2 | Provided unsafely | A notice is attached to a denied call, implying it ran | SC-1 (denials return [DENIED ...] only) |
| UCA-3 | Provided unsafely | The notice alters the audited output or its hash | SC-4 |
| UCA-4 | Not provided | server_info omits a permissive setting |
SC-2 |
| UCA-5 | Provided unsafely | The absence of a notice is read as a safety verdict | SC-3 |
| UCA-6 | Too late | The notice arrives after the caller has already issued a dependent command | Accepted: the command has already run; the notice informs the next decision |
Causal scenarios¶
- CS-1 (UCA-1) A destructive command written so the regexes miss it (quoting,
eval,base64 -d | sh). No notice. Residual and by design: the classifier is not a shell parser. The tests for the covered forms aretests/test_notices.pyandtests/test_classifier_v16.py; fuzz tests bound regex cost only. - CS-2 (UCA-3) A future change formats the notice into
finalbefore the record. Test:test_a_notice_does_not_change_the_audit_recordhashes the body without the notice. - CS-3 (UCA-4) A new permissive setting is added without a warning. Mitigation: review
checklist item in
docs/runbook.mdfor new settings; no automated check. - CS-4 (UCA-5) A model learns that "no notice" means "safe". Mitigation: the wording of the notice and the tool guidance both say the classifier is a heuristic.
- CS-6 (UCA-3) A notice appended after a JSON result makes
json.loadsfail after the remote command already ran, so a caller retries a mutation. Addressed byattach_notices(..., as_json=True)forssh_fanout. Test:test_fanout_keeps_its_json_and_carries_the_notices_inside_it. - CS-5 (UCA-2) A refused call was shown with a notice. Test:
test_a_blocked_call_is_not_a_notice_it_is_a_denial.
Assumptions and limits¶
The analysis assumes the MCP client reads the tail of a tool result. A client that truncates or discards it gets no feedback; the audit record is still complete. The notice is text produced by the server and carries no authority. This is not a safety certification.
Threat analysis¶
Trust boundary: MCP caller to executor. Removing refusals by default does not add a path:
open was already the default, and the controls that exist for an untrusted caller
(authentication, the deny list, guarded/readonly, the confirmation broker, audit) are
unchanged. The notice adds output text; the audit-argument redaction already ran on the data
it reads (only known_hosts is consulted), and the notice contains no command text, so it
cannot leak a secret. STRIDE: information disclosure is not widened; repudiation is not
affected because the record is unchanged; no new elevation path.
Consequences¶
- Clients see up to two extra trailing lines on Tier 2/3 results. A client that matched the
whole result against a fixed string must tolerate the suffix or set
RELAY_SHELL_UNSAFE_NOTICES=false. - Operators who want refusal keep it: set
RELAY_SHELL_POLICY_MODE=guarded(with an allow-list) orreadonly, a deny list, andRELAY_SHELL_CONFIRM_TIER3=true. - Tests:
tests/test_notices.py; the notice is exercised through the real tool wrappers. - Rollback: revert the commit, or set
RELAY_SHELL_UNSAFE_NOTICES=falseto silence the notices without a code change.