Skip to content

Commit 0f610b9

Browse files
author
Neo
committed
Fix gateway fail-direction: gate-fault on a value-moving action now fails CLOSED
Reviewer found gate_action's exception handler failed OPEN (blanket allow=True on any error). Masked under OBSERVE today, but a live fail-open on the money path's outer layer the moment ENFORCE is flipped. Made the fail-direction action-type-aware. - runtime/access_policy.py: could_move_value(action_type) — a coarse PUBLIC fail- direction helper. Deny-unless-clearly-a-benign-read, so a value-moving action can never be missed and wrongly allowed; unknown/empty labels fail closed too. Generic read verbs only — no thresholds, allowlists, or private action sets. The authoritative classification still lives in the private gate. - gateway/security_gate.py gate_action: on a gate fault, value-moving / unknown -> deny (allow=False, would_block=True, generic reason); clearly-benign reads observe-allow so a transient fault doesn't break them. Part 2 sweep also fixed a PRE-EXISTING sibling fail-open (not in b575add) on the primary gated path: ProtocolStack.pre_action's Morpheus except logged and FELL THROUGH (approved stayed True -> tool proceeded). Now fails closed for value-moving actions via the same coarse label (resolving the real action behind the platform_action mega-tool). Benign reads still continue on a transient fault. Flags unchanged (OBSERVE, App Attest enforce OFF, testnet) — this does not enable enforcement; it makes the eventual enforcement safe. Suite 171 green; boundary clean (generic read verbs only).
1 parent b575add commit 0f610b9

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

gateway/security_gate.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,19 @@ async def gate_action(
113113
gate = get_morpheus_security()
114114
return await gate.evaluate(action, ctx)
115115
except Exception:
116-
logger.exception("security gate call failed; allowing (observe)")
117-
return {"allow": True, "would_block": False, "route": "observe", "reason": ""}
116+
# The gate is unreachable / faulted. Fail by ACTION TYPE, not blanket-allow:
117+
# a value-moving (or unknown) action must NOT proceed ungated — fail CLOSED
118+
# (deny) so nothing moves value when we can't reach the gate. A clearly-benign
119+
# read observe-allows so a transient gateway fault doesn't break it. The coarse
120+
# public label is the only input; the authoritative classification still lives
121+
# in the private gate.
122+
from runtime.access_policy import could_move_value
123+
if could_move_value(action_type):
124+
logger.exception("security gate unreachable; FAIL-CLOSED deny (action=%s)", action_type)
125+
return {"allow": False, "would_block": True, "route": "fail-closed",
126+
"reason": _GENERIC_DENY}
127+
logger.warning("security gate unreachable; observe-allow (benign read=%s)", action_type)
128+
return {"allow": True, "would_block": False, "route": "observe-read-failopen", "reason": ""}
118129

119130

120131
def is_blocked(decision: dict) -> bool:

runtime/access_policy.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,44 @@ def _is_state_modifying(action: str | None) -> bool:
6060
return True
6161

6262

63+
# ── Coarse fail-direction (used only when the security gate is unreachable) ──────
64+
#
65+
# Generic read verbs — the ONLY labels treated as safe to observe-allow when the
66+
# security gate can't be reached / faults. EVERYTHING ELSE (anything value-moving,
67+
# owner-gated, or simply unrecognised) FAILS CLOSED. This is a deliberately
68+
# conservative PUBLIC default for one decision — the fail DIRECTION — and is NOT the
69+
# authoritative classification (that lives in the private gate). It is intentionally
70+
# independent of any value-moving list so a fund-moving action can never be *missed*
71+
# and wrongly allowed: we allow only what is clearly a benign read, and deny the rest.
72+
_BENIGN_READ_LABELS = frozenset({
73+
"get", "list", "read", "view", "info", "status", "health", "ping", "ready",
74+
"quote", "quotes", "balance", "balances", "price", "prices", "rate", "rates",
75+
"history", "feed", "search", "lookup", "metrics", "dashboard", "manifest",
76+
"config", "weather", "preview", "estimate", "simulate", "positions", "portfolio",
77+
"profile",
78+
})
79+
_BENIGN_READ_PREFIXES = ("get_", "list_", "read_", "fetch_", "view_", "quote_", "status_")
80+
81+
82+
def could_move_value(action_type: str | None) -> bool:
83+
"""Coarse PUBLIC fail-direction guess: could this action move value or change
84+
security/owner state?
85+
86+
Used for ONE purpose only — choosing which way to fail when the security gate is
87+
unreachable: ``True`` → fail CLOSED (deny), ``False`` → safe to observe-allow.
88+
Anything that is not CLEARLY a benign read (incl. unknown/empty labels) is treated
89+
as value-moving and fails closed. No thresholds, no allowlists, no private action
90+
sets — just generic read verbs. The authoritative classification still lives in
91+
the private gate; this only decides the safe direction on a gateway fault.
92+
"""
93+
a = (action_type or "").strip().lower()
94+
if not a:
95+
return True # unknown → safest direction: treat as value-moving
96+
if a in _BENIGN_READ_LABELS or a.startswith(_BENIGN_READ_PREFIXES):
97+
return False # clearly a benign read → a transient fault may observe-allow it
98+
return True # value-moving / owner-gated / unrecognised → fail closed
99+
100+
63101
def default_agent_access(agent: str | None, tool: str, action: str | None = None) -> tuple[bool, str]:
64102
"""Coarse PUBLIC per-agent access decision. Returns ``(allowed, reason)``.
65103

runtime/protocols/integration.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,21 @@ async def pre_action(
354354
return result
355355
except Exception:
356356
logger.exception("MorpheusSecurity pre-action failed")
357+
# Fail-direction on a gate fault: a value-moving / owner-gated action
358+
# must NOT proceed ungated — fail CLOSED (deny). A benign read may
359+
# continue so a transient fault doesn't break it. Coarse public label
360+
# only; for the platform_action mega-tool the real action is in the
361+
# arguments. The authoritative classification lives in the private gate.
362+
gated_label = tool_name
363+
if tool_name == "platform_action" and isinstance(arguments, dict):
364+
gated_label = arguments.get("action") or tool_name
365+
from runtime.access_policy import could_move_value
366+
if could_move_value(gated_label):
367+
result["approved"] = False
368+
result["denial_reason"] = (
369+
"This action couldn't be authorized right now. Please try again."
370+
)
371+
return result
357372

358373
# Rexhepi gate evaluation
359374
if self._rexhepi_gate is not None:

0 commit comments

Comments
 (0)