Security: approval reply parser resolves negated denials ("no, don't approve") as ALLOW — approval gate fails open

Author: ns-rajatsCreated Aug 19, 2026Updated Sep 6, 2026

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
python
# 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:

python
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.