Security: approval reply parser resolves negated denials ("no, don't approve") as ALLOW — approval gate fails open
Summary
The approval-reply parser resolves negated denials as approvals. resolve_from_reply tests _ALLOW_WORDS before _DENY_WORDS, so a reply like [ow:<id>] no, don't approve matches \bapprove\b first and resolves the item as "allow". InboxStore.resolve is one-shot, so the inverted resolution is final: the suspended agent is released with ApprovalOutcome.ONCE and executes the action the human explicitly denied. Severity: Medium (fires on the fully-authorized approver's own phrasing; the gate fails open).
Call path (verified)
approver replies "no, don't approve" on the bound channel
Gateway._on_inbound [coworker/connectors/gateway.py:122-143]
SessionManager._resolve_inbox_reply [coworker/server/manager.py:2845-2871]
resolve_from_reply [coworker/inbox_routing.py:131-142]
_ALLOW_WORDS tested first (line 136): "approve" matches inside the negation → resolution = "allow"
InboxStore.resolve (one-shot) [coworker/inbox.py:311-325]
approval_outcome("allow") → ApprovalOutcome.ONCE [manager.py:2686-2687] → denied action executes# coworker/inbox_routing.py:27-28
_ALLOW_WORDS = re.compile(r"\b(?:approve|approved|allow|allowed|yes)\b")
_DENY_WORDS = re.compile(r"\b(?:deny|denied|reject|rejected|no)\b")The comment at line 26 shows substring pitfalls ("disallow" → allow) were already patched once; negated phrases were missed.
Fix
Fail closed on mixed intent — evaluate deny first, or treat both-match as ambiguous and keep the item pending:
has_allow = bool(_ALLOW_WORDS.search(lowered)) or "" in reply or "✅" in reply
has_deny = bool(_DENY_WORDS.search(lowered)) or "" in reply or "❌" in reply
if has_deny: # deny wins on mixed/negated phrasing — the gate must fail closed
resolution = "deny"
elif has_allow:
resolution = "allow"
else:
resolution = _ID_TOKEN.sub("", reply).strip()Verify the fix
Unit test: [ow:x] no, don't approve, [ow:x] not allowed, [ow:x] no, do not allow this must all resolve as deny (or stay pending), never allow.
Found during a SAST review (verified reachable call paths only) of commit fc3aa28.
Source: andrewyng/openworker