ADR-0083: Tool-name-keyed policy rules and declared human confirmation¶
- Status: Accepted
- Date: 2026-09-01
- Related: engineering plan Sections 9.2, 9.3, and 11.2; ADR-0017, ADR-0081
- Detailed design:
docs/plan/policy-and-approvals.md,docs/plan/device-channel-and-sms.md
Context¶
ADR-0081 decided that device.sms.send classifies ALLOW, because iOS makes
the owner's Send tap non-bypassable and a second in-app approval would duplicate
rather than add control. Implementing that decision exposed three things the
ADR did not settle, all of which are authorization mechanics and therefore
belong in an ADR of their own.
First, there was no seam. The deterministic layer is a total matrix keyed on
SideEffectClass. device.sms.send is EXTERNAL_MESSAGE, whose row requires
approval, and the loader's profile keys and rule keys are closed sets with no
per-tool override. The only way to reach ALLOW without a new seam was to relax
the EXTERNAL_MESSAGE row, which would have changed the decision for every
messaging tool in the system to buy one tool's exemption.
Second, ALLOW alone was unreachable anyway. The trust overlay raises any
non-read ALLOW to REQUIRE_APPROVAL when an argument carries
EXTERNAL_UNTRUSTED, and _argument_trust() labels every model-authored
argument exactly that. A matrix seam without an overlay change would have
produced an entry that never fires.
Third, the first narrowing suppressed the wrong half. The initial implementation kept the overlay's origin test and dropped its argument test, and described that as "untrusted input can never drive one of these entries to a plain allow". A security review falsified the claim in three ways:
_turn_origin_trust(tools/executor.py) walks back only to the most recentUSER-trust user message. Origin trust is therefore scoped to the active turn. Turn one ingests attacker content and is tainted; the owner then types "ok"; turn two carries aUSERorigin and an attacker-chosen recipient and body reaches a plainALLOW.- Probes reached
ALLOWwithorigin_trustofMEMORYand ofKNOWLEDGE. The trust table in policy-and-approvals.md marks both "May authorize: No". They passed only because the overlay tested equality against one label, andKNOWLEDGEcollapses intoEXTERNAL_UNTRUSTEDtoday by accident of_turn_origin_trustrather than by rule. device.sms.sendwas absent fromFORBIDDEN_CHILD_TOOLSwhile delegated child runs seed atUSERtrust, giving a third laundering path.
Decisions¶
-
A
tool_rulessection, keyed on the exact tool name. A profile may map a tool name to a row of the same shape as a side-effect row. When an action's name matches, that row decides for that one tool. The side-effect matrix stays total and every other tool in the class keeps the class's decision. This is the seam a milestone uses instead of editing a matrix row, and it keeps the normative Section 9.2 table intact and readable as written. Loader validation is closed-key and strict, matching the rest of the profile: a malformed section, an invalid tool name, an unknown field, a missing or unrecognized decision, or an unrecognized condition all refuse to load. Two entries for one tool name are unclassifiable and deny. -
Overlay suppression is opt-in through
human_confirms_arguments: true. A tool rule narrows nothing by itself;decision: allowwithout the flag gets no suppression and the argument half of the trust overlay applies as usual. The flag is a specific, checkable claim about the named tool: it shows the arguments to the human who completes the action. Making it explicit rather than implied by the entry's existence means a future entry cannot inherit an exemption it has not earned, and it gives review something to argue with. -
The origin half follows the trust table's "May authorize" column. For a tool-ruled action, only
PLATFORM,TRUSTED_CONFIGURATION, andUSERorigins reach a plainALLOW;INTERNAL_TOOL,MEMORY,KNOWLEDGE,EXTERNAL_UNTRUSTED, and any future non-authorizing label escalate toREQUIRE_APPROVAL. This replaces an equality test against one label with the rule Section 11.2 already states, and it stops depending onKNOWLEDGEcollapsing intoEXTERNAL_UNTRUSTEDby accident. The matrix path's overlay is left exactly as it was; this narrowing applies only where a tool rule fired. -
The real trust model is the compose sheet, and the ADR says so. The primary, non-bypassable control for
device.sms.sendis the platform's own confirmation: iOS shows the owner the recipient and the body and does not send without a tap (ADR-0081). Policy's origin escalation is defense in depth layered on top of it, not the thing that makes the tool safe. -
The cross-turn residual is accepted and named. Because origin trust is turn-scoped, an injection in one turn followed by any owner message produces an authorizing origin on the next. Policy does not catch this and no turn-scoped signal can. The accepted residual is that the owner sees the attacker-chosen recipient and body in the compose sheet and can decline. This is written into
policy-and-approvals.mdso no future reader mistakes the origin check for containment. Closing it properly needs a durable per-session taint that survives user messages, which is a context-engine change and is not in scope here. -
device.sms.sendjoinsFORBIDDEN_CHILD_TOOLS. A delegated child seeds atUSERtrust, so any tool a child may call is reachable from whatever authored the brief. Drafting a reply is a triage-session behavior under the owner's eye, never a child-run behavior. This is a rule about the tool, not about trust labels, so it holds regardless of how child seeding evolves. -
Ingest-path turns are untrusted by construction. The SMS ingest path seeds its triage turns with device-originated untrusted content and stamps that trust on the seed, so a triage turn's origin is non-authorizing and its
device.sms.sendproposals escalate to approval. That is whatdevice-channel-and-sms.md's statement about untrusted turns rests on, and it is a gate-10 obligation on the ingest work rather than something the policy layer can assert on its own.
Consequences¶
- The profile schema grows one optional section and one rule property. Existing
profiles without
tool_rulesload unchanged. PolicyDecision.reason_codegains thepolicy.tool.{name}form alongsidepolicy.matrix.{class}, so an audit trail says which layer decided.LoadedRulesetgainstool_rules; the ruleset stays frozen and hashable, andpolicy_versionstill covers the whole document.- A reviewer auditing exemptions can list them: every entry in
tool_ruleswithhuman_confirms_arguments: trueis a claim that a human sees the arguments, and there is exactly one today.
Alternatives considered¶
- Relax the
EXTERNAL_MESSAGErow with a condition. Rejected: the row is normative in Section 9.2 and shared by every messaging tool; a per-tool predicate hidden in a class row is harder to audit than a per-tool entry. - Suppress the overlay entirely for a tool rule. Rejected: it discards the defense-in-depth layer for no gain, and it was the shape the security review falsified.
- Model the compose-sheet tap as standing authorization. Rejected: it keeps
the decision at
REQUIRE_APPROVALand satisfies it with evidence, which contradicts ADR-0081's plain statement that policy classifies the toolALLOW, and it would create an approval record for an approval that never happened in the product. - A durable per-session taint instead of turn-scoped origin trust. Deferred, not rejected: it is the real fix for the cross-turn residual, it changes context-engine behavior for every tool rather than one, and it wants its own design and its own gates.