Skip to content

fix(inbox): a negated allow word is a refusal, not an approval - #445

Open
ishita199615 wants to merge 1 commit into
andrewyng:mainfrom
ishita199615:fix/inbox-reply-negated-approval
Open

fix(inbox): a negated allow word is a refusal, not an approval#445
ishita199615 wants to merge 1 commit into
andrewyng:mainfrom
ishita199615:fix/inbox-reply-negated-approval

Conversation

@ishita199615

Copy link
Copy Markdown

The problem

Reply "not approved" to an approval request in Slack and OpenWorker approves it — the email goes out.

resolve_from_reply (coworker/inbox_routing.py:137) checks allow before deny and stops at the first hit:

if _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply:
    resolution = "allow"
elif _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply:
    resolution = "deny"

_ALLOW_WORDS is \b(?:approve|approved|allow|allowed|yes)\b. "not approved" matches approved, so the first branch wins and the elif never runs. The not is never read.

#161 added those word boundaries so "disallow" stopped matching allow. Boundaries fix matching inside a word, not the word in front of it.

On main at 01b6f83, against a real InboxStore:

before

Short answers (no, deny) work. Full-sentence refusals flip.

Why it matters

This is the live path for Slack/Telegram approval replies — manager.py:2830 _resolve_inbox_reply routes inbound messages here for kind="approval" items, which gate sends, shell commands and file writes. The ownership check above it decides who may answer; nothing checked what they meant.

It fails in the wrong direction. A misread approval costs a second click. A misread refusal has already sent the email.

The fix

allowed = _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply
denied = _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply
if _NEGATED_ALLOW.search(lowered) or denied:
    resolution = "deny"
elif allowed:
    resolution = "allow"

Deny is tested first, so a reply with both intents ("denied - do not allow") lands closed. And a new pattern catches the case with no deny word at all:

_NEGATED_ALLOW = re.compile(
    r"\b(?:not|never|no|nope|cannot|can'?t|won'?t|don'?t|doesn'?t|didn'?t|isn'?t|"
    r"shouldn'?t)\b(?:\W+\w+){0,2}?\W+(?:approve|approved|allow|allowed|yes)\b"
)

The {0,2} window is deliberate. ask_user questions reuse this parser, so a broader rule would start swallowing free-text answers. "did not surface anything new, so approve" still resolves as allow — there's a test pinning it.

Emoji reactions, plain approvals and the free-text path are unchanged.

before

Tests

6 new cases in tests/test_inbox_routing.py, plus 3 pinning what must not change, alongside the ones #161 added.

  • Source reverted (git checkout main -- coworker/inbox_routing.py): 6 failed, 15 passed
  • With the fix: 21 passed
  • Full suite: 1121 passed, 1 skipped

resolve_from_reply tested the allow list before the deny list, so the first
branch won on any reply containing an allow keyword and the deny branch was
never reached. Word boundaries (andrewyng#161) stopped "disallow" matching allow, but
they cannot see the word in front: "not approved" contains "approved", so a
refusal typed into Slack or Telegram resolved the item as allow and the parked
action went ahead.

Reproduced on main against InboxStore - "not approved", "I don't approve",
"no, do not approve this", "please don't allow that" and "never approve this
one" all resolved to allow, as did "denied - do not allow" (both intents
present, allow tested first).

Deny is now tested before allow, so a reply carrying both intents lands closed,
and a new _NEGATED_ALLOW pattern catches an allow keyword behind an adjacent
negator - the case with no deny keyword at all to fall through to. The window
is bounded to two intervening words so it reads adjacency rather than a negator
appearing anywhere in the message; prose like "did not surface anything new, so
approve" still resolves as allow. Emoji checks, plain approvals and the
free-text answer path are unchanged.

As andrewyng#161 put it, a false allow is the worst-case direction - this path gates
parked unattended actions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant