sonichi / mention-gate
Turn on/off whether a message that @-tags the OWNER counts as a mention of the bot. Off (default) is today's behavior — in requireMention channels such messages never reach the fleet. On, they are ingested as tasks and audit-logged. Free-listen channels are unaffected either way.
Mention Gate
Today, in every requireMention channel, a message @-tagging the owner (but not
the bot) is never ingested — it stays invisible to the fleet. This skill adds
the missing ON side:
ON — an owner-tagged message from anyone but the owner counts as a bot
mention: it passes the requireMention check and becomes a task. Every
message pulled in this way is appended to a durable audit log so the owner
can review exactly what the toggle admitted.- OFF (default) — today's behavior, unchanged.
Never affected: messages from the owner himself (they follow the existing
rules), messages that actually mention the bot (ingested in both states, as
always), and requireMention:false channels (everything there already
ingests, gate on or off). Later access gates (channel allowFrom, tier
resolution) still apply to an admitted message — the gate substitutes only the
missing bot-mention, nothing else.
Fail-closed: the feature ADDS ingestion, so every failure reads as OFF —
missing or malformed state, an unparseable until, or any exception inside
the bridge's gate check leaves the ordinary requireMention rejection standing.
A broken state file must never surprise-ingest.
Usage
python3 skills/mention-gate/scripts/mention-gate.py on # owner tags trigger ingestion
python3 skills/mention-gate/scripts/mention-gate.py on --for 2h # with auto-expiry back to off (30m/2h/1d)
python3 skills/mention-gate/scripts/mention-gate.py off # today's behavior (default)
python3 skills/mention-gate/scripts/mention-gate.py status # state + how many messages pulled inState + audit
Scope: PER-HOST, not fleet-wide
The gate is stored under <workspace>/state/, which vault.sync.exclude lists
explicitly — the vault runs in whitelist mode and carries only notes/,talks/, hosts/ and the memory tree. git ls-files state/mention-gate.json
returns nothing on a live host. So **toggling the gate changes only the host it
was run on**; every other host keeps its own setting. Run the command once per
host. There is currently no synced fleet-policy surface to store it on.
Gate state:
<workspace>/state/mention-gate.json—{"mentions_enabled": bool, "until": "<ISO-8601>"|null}(mentions_enabled
= this gate is on), written atomically (temp sibling +os.replace).untilauto-expiry needs no follow-up write: once passed, the gate reads
OFF.Audit log:
<workspace>/state/mention-gate-ingested.jsonl— one fsync'd
JSON line per admitted message `{ts, channelid, authorid, message_id,
body (first 120 chars)}.status` reports the count. The row is appended
only AFTER the admitted task file is durably written — a sender the later
authorization gates (channel allowFrom, global allowlist) reject leaves no
audit row, so an unauthorized sender can never inflate the audit (#3473).
Architecture
Policy owner (core):
src/mention_gate.py—read_state/write_state
/owner_tag_triggers_ingest(fail-closed) / `messagetagsowner(mention_ids,
text, ownerids)/loggatedingest/gatedingest_count`. Owner ids are
parameters; the module hardcodes none.Discord adapter (wired):
src/discord-bridge.py—_mention_gate_triggers_ingest(message), consulted inside_handle_discord_messageat the requireMention rejection site (therequire_mention and not bot_mentioned and not role_mentionedbranch):
when it returns True the rejection is bypassed and the message continues
down the existing pipeline; otherwise (including on any error) the ordinary
skip stands. The helper returns a verdict only; the audit row is written by_mention_gate_log_admissionright after_write_task_filesucceeds.
Owner-tagging is detected from the platformmessage.mentionsarray plus a<@ID>/<@!ID>text fallback. Owner ids come fromaccess.json: a
PRESENTtierMapis authoritative — only its explicitownerentries
count, and an empty map means no owners (the gate never triggers);allowFromis consulted only when thetierMapkey is absent (legacy
file), so read-only members are never promoted to owner for this gate.ag2space/Matrix adapter (NOT wired — documented instead): verified by
reading the code: gateway messages become task files inside_write_taskinpackages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py, which is
package-canonical (PyPI-published, intentionally divergent fromsrc/; thesrc/remote-gateway-bridge.pyloader only injects dirs/seams and execs it —
no per-message ingest passes through repo-level code first). The broker also
makes the mention-match decision (is_mention) server-side, so wiring here
would fork the vendored contract. Where the trigger belongs: an injectable
ingest-trigger seam in the package (mirroringlocal_task_protocol.set_task_stamper), consulted where_write_task's
callers decide whether a room message is for this agent; the loader would
bind it tomention_gate.owner_tag_triggers_ingest+message_tags_owner
with the workspace it already resolves and the owner mxid from its channel
config. Until that package change lands, the gate has no effect on
ag2space/Matrix rooms.
Tests
tests/mention-gate.test.py — fail-closed state contract (default/missing and
malformed state read OFF, unparseable until reads OFF, atomic write),
tagging detection (mentions array + text fallback), injected-now expiry
flipping ON→OFF, audit-log order + count, owner-id resolution controls (an
explicitly empty tierMap yields no owners and the gate never triggers; a
present tierMap beats allowFrom; absent tierMap falls back), the production
chokepoint matrix on _mention_gate_triggers_ingest (verdict-only — no audit
at verdict time; OFF and default-state reject as the fail-closed control;
tags-neither, bot-only-mention and owner-authored never trigger; any error
fails closed), full-handler behavioral drives (gate ON + authorized → task
written then exactly one audit row; gate OFF/default → neither; unauthorized
sender → neither), AST pins (gate consulted in the requireMention rejection;
audit call strictly after the task write; verdict helper contains no audit
call), and a CLI round trip.
Live witness (post-restart)
skills/mention-gate/scripts/witness.py case1|case2|case3 --marker <unique> —
run on the host with the restarted bridge; the operator sends the test
message from a non-owner account and the script verifies task + audit
outcomes for: gate OFF rejects; gate ON admits an authorized tagged message;
gate ON + unauthorized sender leaves neither task nor audit.