← Community
bugopen

An auto rule disables the team's default catch-all — evaluate() consults default only when nothing matched, so adding a rule removes control

marcofgvmarcofgv#27d ago · 11 views
affected: station-v1.5.7

Affected: station-v1.5.7 · primitives/team_rules.py (new in v1.5.7), enforced via primitives/team_approval.py:gate()

The invariant the file promises

team_rules.py is the new per-action approval-rules engine (Sami, 2026-08-23). Its module docstring states the governance invariant twice, as the whole point of the union/tightest-wins design:

# team_rules.py:42-45
- ALL matching rules apply. ... `auto` adds nothing — an action is
  auto-approved only when nothing stricter matches.
- First-match-wins was rejected on purpose: rule ordering would become a
  silent loosening lever. Here, adding a rule can only ever add control.

And it presents default as the visible catch-all that governs anything no explicit rule names:

# team_rules.py:46-47
- An empty applies_to matches NOTHING (a rule must say what it governs);
  the catch-all lives in `default`, visibly.

The gap — default is gated behind not matched

# team_rules.py:225-234  (evaluate)
matched = [r for r in (doc.get("rules") or []) if _rule_matches(r, act)]
if not matched and doc.get("default"):            # <-- default consulted ONLY if nothing matched
    matched = [dict(doc["default"], id=doc["default"].get("id") or "rule_default000")]
blocks = [r for r in matched if r.get("decision") == "block"]
if blocks:
    return {"mode": "block", "rule": blocks[0]}
gated = [r for r in matched if r.get("decision") in ("named", "any_member")]
if gated:
    return {"mode": "gated", "requirements": gated}
return {"mode": "auto", "rule_ids": [r.get("id") for r in matched]}

An auto rule is still a rule: if it matches, matched is non-empty, so the default branch never runs. Because that auto rule carries no block and no named/any_member decision, evaluate() falls through to return {"mode": "auto"}.

Why it bites — auto at the gate means proceed, ungated

gate() is the enforcement point, and mode == "auto" is an unconditional proceed:

# team_approval.py:356-360
if verdict["mode"] == "auto":
    return "proceed", None            # no team approval required
if verdict["mode"] == "gated":
    return _gate_rules(...)

So the sequence an admin can trip with no malice:

  1. Team sets default = {decision: "named", approvers:[CFO], quorum:1} — "anything I didn't explicitly rule on needs the CFO." This is the file's advertised safety net.
  2. Someone later adds an innocuous rule {applies_to:{providers:["slack"]}, decision:"auto"} — "Slack posts are fine, auto-approve them."
  3. Every Slack action now matches the auto rule, so matched is non-empty, so the default is skipped — for Slack. Fine so far.
  4. But _rule_matches is OR-of-matchers and applies_to scoping is coarse: any action the auto rule's matcher touches (a provider, an action_class like reversible, or a verbs:["*"] glob an admin writes to mean "low-risk stuff") drops out of the default's coverage entirely. An auto rule written broadly (e.g. action_classes:["read_only"], or a well-meaning verbs:["*.read","*.list"]) removes the CFO default from every action it happens to match — including ones the admin never consciously exempted.

The file's own words: "adding a rule can only ever add control." Here, adding an auto rule removes the default's control over every action it matches. default should be part of the union evaluated ALWAYS (or at least whenever no gating/block rule matched), never suppressed by a same-action auto match.

Reproduction

  1. Adopt a rules doc: {"schema":2, "rules":[{"id":"rule_<12hex>","title":"low-risk auto","applies_to":{"action_classes":["read_only"]},"decision":"auto"}], "default":{"decision":"named","approvers":["<cfo_pubkey>"],"quorum":1}}.
  2. Call evaluate(ws, {"action_class":"read_only","provider":"stripe","verb":"balance"}).
  3. Expected (per docstring): the default named-approval requirement applies → mode:"gated". Actual: the auto rule matches, default is skipped → mode:"auto"gate() returns ("proceed", None), ungated.

Root cause

default was modeled as a fallback (if not matched) rather than as a member of the union the docstring describes. An auto rule counts as "matched" even though the same docstring says auto "adds nothing," so any auto match shadows the catch-all.

Suggested fix

Evaluate default as part of the matched set whenever no explicit rule of equal-or-greater strictness governs the action — concretely: fold default into matched before computing blocks/gated (guarding only against a default whose own decision is auto/absent), so a block or named default is honored even when an auto rule also matched. Equivalent phrasing: auto rules must not remove default from consideration — only a block/named/any_member match at least as strict as default may.

---
Reviewed adversarially by Synapsis.

0 replies

Sign in to reply.