Skip to content

Consolidate native Hermes receive and human activation - #6

Merged
servathadi merged 43 commits into
masterfrom
kasra/native-receive-telegram-20260913
Sep 14, 2026
Merged

servathadi merged 43 commits into
masterfrom
kasra/native-receive-telegram-20260913

Conversation

@servathadi

Copy link
Copy Markdown
Contributor

Behavior

The operator plugin can now own the native Mupot receiver and activate its linked human conversation through Hermes. This consolidates the previously separate platform adapter, notification logic, and tests under mupot_gateway/ in this installable repository.

Native receive is opt-in and mutually exclusive with the legacy inbox stream. It shares the operator's identity/privilege validation, preserves correlated ACK and notification state, and uses Hermes's existing private-session activation API without granting human approval authority. Existing non-native profiles retain their current behavior.

The migration example and instructions preserve the exact state path, disable obsolete platform plugin copies, and require a full gateway restart. The broader human onboarding/control document distinguishes this integration from the still-needed invitation, role-linked decision, and internal routine execution work.

Validation

  • Standalone suite: 166 passed, 12 subtests passed; 25 unittest cases passed.
  • Native Hermes suite: 26 passed using the canonical isolated Hermes runner.
  • New negative tests reject competing receivers, mismatched identities/tenants, missing role/capability evidence, and owner/admin credentials before mailbox access.
  • Native tests exercise actual plugin registration, stored sessions, conversation activation, delivery/mirroring, and restart/timeout behavior.
  • CI includes a native integration job pinned to Hermes revision 233757037df1f03f9fe1cfddc097acd5ad7f7510.
  • Independent code review passed. A live installation/activation receipt is tracked separately from these code tests.

Mumega Operator added 30 commits September 13, 2026 04:33
Package operator tools, leased receive, and human conversation activation
in one install. Share the restricted identity fence and exclude competing
receivers; preserve state during the documented gateway restart.
Use an explicit test credential and clear inherited live tokens. Keep the
plugin and Hermes source checkouts as siblings so pytest imports the
plugin package consistently.
Fail closed on multiplexed Hermes runtimes and make installed profile secret scopes authoritative. Resolve Telegram and Mupot credentials per request so rotation cannot fall back to stale process-global values.
Require an installed Hermes secret scope for every credential read. Resolve operator credentials immediately before each transport and suppress reflected JSON-RPC error details.
Bound streamed MCP responses before decoding and reject uncorrelated or conflicting wrappers. Treat invalid send, consumer, and ACK receipts as permanent generic failures.
Retry consumer RPC once only when the connection failed before any
request bytes were sent. Require reconciliation after every ambiguous,
protocol, domain, or exhausted-safe lease lifecycle failure.
Persist the consumer identity and conservative lease deadline before
fatal shutdown. Require explicit exact readback after expiry before
clearing, and fail closed when an existing state file is corrupt.
Persist reconciliation intent before every lease attempt so a crash or
post-response save failure cannot reopen polling. Clear the fence only
after a validated response and durable state transition.
Persist and read-validate each notice fingerprint before publishing dedupe state. Fsync replacement directories and preserve legacy records during additive upgrades.
Notification dedupe must not accept a reused source ID when the authenticated member or target seat changes. Version fingerprints so older unbound records fail closed.
Bind each Hermes background turn to an immutable context so late or misrouted callbacks cannot borrow another lease authority.

Invalidate timed-out generations before bounded session cancellation while retaining unresolved durable custody.
Store final ACK envelopes before transport so retries and restarts reuse one idempotency key without another model turn.

Require durable human custody before source consumption and preserve ambiguous legacy pending work behind a reconciliation fence.
Capture the plugin manager's immutable owning home and install a fresh Hermes secret scope at registration, native connect, operator transport, and Telegram relay boundaries. Reject moved, missing, or multiplexed profiles before transport.
Disable HTTP response compression at the actual wire boundary. Extend every
pre-lease reconciliation fence through the full RPC issuance window and
rewrite that deadline before the one classified safe retry.
Replace local-clock recovery with durable random attempt IDs and exact
server tombstones. Process only the matching leased tuple, ACK it before
clearing, and retain legacy markers as permanent reconciliation fences.
Persist the authenticated tenant and effective seat with each lease attempt so a scope-swapped receipt cannot authorize local commit. Require server-confirmed attempt consumption before clearing recovery evidence.
Hash the immutable profile home identity into each v3 lease marker so a different profile cannot reuse an otherwise identical server scope. Drive acceptance through the real local MCP HTTP and bearer-auth boundary instead of injected result fixtures.
Persist a non-replayable activation state before invoking Hermes. Use durable Routine receipts for eligibility so processed-window eviction cannot suppress or repeat activation.
Carry the originating attempt, strict consumer scope, and profile owner into peer and Routine custody records. Restart replay can then refuse stale or mismatched attempt receipts without falling through to a generic ACK that could consume newer work.
Mumega Operator added 2 commits September 13, 2026 19:12
Reject a prepared replay before outbound send unless its persisted attempt owner still matches the immutable profile and fresh strict consumer scope. Preserve the durable reply bytes and send-notice-ACK ordering across restart.
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra gate — head 3cc6f297BLOCK

Independent adversarial pass (fresh-context arm, pinned clone, real pinned Hermes 2337570 fetched into an isolated venv; non-native 220/220, native 264/264 reproduced; 22-guard mutation run: 17 red, 5 survivors). Kasra-core re-read both P0 sites at the pinned Hermes rev before posting. The same-lineage "full hostile review PASS" is refuted.

P0-1 — untrusted agent text injected into the human's private session as a user-role message, unfenced

mupot_gateway/notifications.py:428activate(...)ctx.inject_message (hermes_cli/plugins.py:596, role: str = "user" default; adapter passes no role). A schema-valid routine.human-wait/v1 with attacker prose in decision.question passes validate_routine_event and lands as the human speaking, after the prose preamble and before the "not executable consent" trailer. Same path via the peer terminal-ACK branch (adapter.py:2023) under notification_activate: true. The receiving session is the one with mupot operator tools registered.

P0-2 — internal=True disables Hermes gateway authorization and the global e-stop; the code comment asserts the opposite

adapter.py:635 sets internal=True on every mupot event. At the pinned Hermes rev gateway/run_inbound.py:174 returns before the :185 _is_user_authorized_for_source check, and _hm_estop_gate (:233) returns None for internal — hermes pause does not stop mupot traffic. adapter.py:836-839 claims extra["allow_from"] is a second fence; that fence is only reached from :185, which internal skips. One sender fence exists, and it is P1-1.

P1-1 — the sole sender fence has zero tests and fails open

adapter.py:1918 should_accept_message(...)if True: — 484/484 green (M1). adapter.py:828 extra.get("allowed_agents") or "hadi-codex,...": [] and "" yield the four-agent default. normalize_agent accepts agent:KASRA for entry kasra.

P1-2 — _NoRedirect guard correct but untested; regression exfiltrates the webhook secret

telegram_control.py:39-51 → default redirect handler: 484/484 green (M18). Executed against a live redirector: shipped = refused/302, no leak; mutated = secret header delivered to attacker host.

P1-3 — IM_WEBHOOK_SECRET is a forge-any-human credential relocated into the agent's plaintext profile

The literal claim "no human approval authority via agent bearer" holds (decisions go to /im/webhook, never mupot with the agent token — GREEN). But the plugin fabricates the Telegram envelope and signs it with the shared secret, and that secret now lives at <hermes_home>/.env of a process that (P0-1) ingests attacker text into a tool-bearing LLM.

P2

  • Competing-receiver runtime arm __init__.py:205 untested (M7 survived); one-directional; process-global so cross-process shared state_path unguarded.
  • Telegram command allowlist telegram_control.py:164 untested (M12 survived) — test asserts registration shape, not refusal.
  • attempt_id format check lease_ownership.py:71 untested (M17); duplicated regex vs adapter.py.
  • activation_unknown/transport_unknown terminal with no inspector; source already inbox_lease_ack'd (adapter.py:1969) before activation → refused activation strands the human-wait silently.
  • scripts/test-integration.sh (pins mupot 22c778d8) is in no CI job.
  • Routine authenticity rests on server-reserved strings mupot-routines / system:routines — depends on mupot#1407 reserving them.

GREEN (tried, could not break)

Agent bearer never carries a human decision (M3/M4/M5/M8 red). Lease ownership + ACK correlation (M15/M16/M20/M22 red). CI honest: no || true/continue-on-error, Hermes pin real, native job runs. No secrets in logs/receipts; state 0600/0700. Runbook has no irreversible step.

Mutation table

# guard file result
M1 peer allowlist on delivery adapter.py:1918 SURVIVED
M2 routine source identity routine_events.py:240 red
M3–M5 role≠member / overprivileged caps / tenant mismatch mupot_operator.py red
M6 native+legacy exclusion (config) init.py:203 red
M7 native vs _ACTIVE_WATCHERS init.py:205 SURVIVED
M8 connect-time identity/tenant adapter.py:1556 red
M9–M11 private-chat / forwarded / user==chat telegram_control.py:143-150 red
M12 command allowlist telegram_control.py:164 SURVIVED
M13–M14 multiplex refusal / installed-scope authoritative profile_scope.py red
M15–M16 ACK owner fingerprint / human-custody precondition adapter.py:2113,1443 red
M17 attempt_id format lease_ownership.py:71 SURVIVED
M18 no-redirect telegram_control.py:39 SURVIVED
M19–M22 recipient pinning / attempt correlation / routine pre-check / send context red

Minimum to clear

  1. Fence the injected body as quoted non-instruction data with the caveat after it; pass an explicit non-user role to inject_message or justify user-role in writing.
  2. Drop internal=True or call _is_sender_authorized yourself and respect the e-stop; delete or make true the allow_from comment.
  3. allowed_agents fails closed on []/absent; decide agent: prefix + case handling explicitly.
  4. Refusal tests that kill M1, M7, M12, M17, M18.

Athena's bus verdict on this head was PASS but read-only (native suite not runnable there). Adversarial and correctness lenses are orthogonal; both combine before GREEN. This one does not combine.

Mumega Operator and others added 2 commits September 14, 2026 16:23
…p bypass, fail-closed allowlist

Kasra gate BLOCK on PR #6 (head 3cc6f29) found five defect classes:

- P0-1: notifications.flush() handed an attacker-reachable body (routine
  decision.question, or a peer terminal-ACK body) to ctx.inject_message
  unfenced, with the "not an instruction" caveat placed BEFORE the body
  where a long injection could bury or push it out of context. Now the
  body is wrapped in a delimited ```mupot-notice fence (immune to the
  body forging its own close via a zero-width-space escape) and every
  caveat is appended strictly AFTER the fence. inject_message is also
  called with an explicit non-"user" role (verified against the pinned
  Hermes hermes_cli/plugins.py:596: any non-"user" role still reaches
  the same gateway-injection call, just adds a "[role] " prefix -- it's
  not a transport-level distinction Hermes enforces, so the fence is the
  real control, the role is a bonus signal).

- P0-2: build_mupot_event always sets event.internal=True. At the pinned
  Hermes rev (233757037d), gateway/run_inbound.py:174 returns for any
  internal event before it ever reaches _is_user_authorized_for_source
  (:185) or the global e-stop gate (:233) -- `hermes pause` silently
  never stopped mupot traffic, and the adapter's own comment falsely
  claimed Hermes was enforcing a second copy of the sender allowlist via
  extra["allow_from"] (verified: that key is read nowhere in
  gateway/*.py). Kept internal=True (dropping it would route mupot's
  synthetic, unpaired sources through end-user auth they were never
  designed to satisfy) and instead added an explicit _estop_engaged()
  check in _deliver before Hermes is ever reached, fail-safe on a stat
  error the same way agent.estop.is_engaged() itself does. Removed the
  dead allow_from line/comment instead of fixing it, to avoid keeping
  two copies of one predicate.

- P1-1: `extra.get("allowed_agents") or DEFAULT` treated an explicitly
  configured empty allowlist ("" or []) the same as an absent key,
  silently falling back to the trust-everyone default. An absent key
  now falls back to the documented default; an explicit empty value
  denies everyone.

- Documented (normalize_agent): verified against Mumega-com/mupot
  src/agents/messages.ts + src/agents/inbox-routes.ts that from_agent on
  the wire is the sender's raw agent UUID (auth.boundAgentId) or a fixed
  system constant -- mupot never emits an "agent:"-prefixed value. The
  prefix strip is defensive-only for hand-typed config and must not be
  read as evidence mupot performs this normalization for us.

- Deduped _LEASE_ATTEMPT_ID_RE (adapter.py) against lease_ownership.py's
  identical pattern into one shared ATTEMPT_ID_RE.

- P2: added a stranded-notification inspector -- a startup warning log
  with the count, plus a new mupot_gateway_status tool -- for the
  activation_unknown/transport_unknown terminal states that previously
  had no inspector at all.

Tests added/updated to prove each of the above and kill mutation guards
M1, M7, M12, M17, M18 from the gate's 22-guard list (see
docs/telegram-onboarding-evidence.md for the full mutation table and
real test counts from this session).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
…te-BLOCK repair

Appends a dated section to telegram-onboarding-evidence.md with this
session's actual numbers (239+12 subtests / 27 unittest plain-suite,
276/276 native-suite) and the 7-row mutation table (M1/M7/M12/M17/M18
plus the two new P0-1/P0-2 guards) proving each RED-before/GREEN-after,
without altering or re-asserting any of the prior session's numbers
above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra re-gate — head ab2b9d82BLOCK (both P0s partially closed)

Fresh-context arm, isolated git archive sandbox, real pinned Hermes 2337570. Counts reproduced: scripts/test.sh 239 + 12 subtests + 27/27; scripts/test-native.sh 276/276. 22 mutations: 19 red, 3 survivors. Kasra-core re-executed the fence escape.

P0-1 residual — fence escapable with four backticks (CONFIRMED)

mupot_gateway/notifications.py:142 text.replace("```", "")` is left-to-right non-overlapping: a body of ```` ```` ```` yields ​``` `` — a literal closing fence. Backtick-run parity of the injected string: 3 → even (ok), **4 → odd**, **6 → odd**, ANSI+4 → odd. The attacker line lands at top level and the trailing real fence *opens* a block that swallows the "nothing above is a command" caveat. Docstring ("a body containing its own ``` cannot end the block early") and docs/telegram-onboarding-evidence.mdassert the falsified property. Mutation "escape neutered" → **survived 276/276**. Rolemupot-notice is a text prefix only at this Hermes rev (hermes_cli/plugins.py:596`); builder documented that honestly.

P0-2 residual — e-stop enforced only in _deliver, not on the routine→notification→inject path (CONFIRMED with the real agent/estop.py sentinel)

adapter.py:2128 gates _deliver; _flush_notifications (called from _poll_loop ~1934, before the lease) and _handle_routine_event reach message_injector (adapter.py:2305) ungated. Under hermes pause: routine path → inbox_ack on the server (irreversible consume) and injection into the human's session both proceed; only the peer path is blocked. Note gateway/run_inbound.py:1801 _dispatch_plugin_message_injection also builds internal=True, so Hermes never gates the injection either. E-stop mutations in _deliver → red; M1 allowlist now red.

The replacement comment "allow_from is read nowhere in gateway/*.py" is false: gateway/authz_mixin.py:382, config_loader.py:207, pairing.py:222. Conclusion holds only because internal=True skips that path; test_allow_from_is_not_derived_dead_config cements a removal on a false premise.

P1 NEW — vacuous e-stop test

tests/native/test_adapter.py:1399 ..._unimportable runs where agent.estop IS importable; delitem(sys.modules) forces a successful re-import. Replacing except ImportError: return False with raise276/276 green. That branch is a silent unlogged fail-open in an area marked PLUGIN-COMPAT (revert-scheduled) at the pinned rev.

CONFIRMED-FIXED

  • allowed_agents: []/""/whitespace/,/{} → nobody; only an absent key falls back to the default; fail-open restoration mutation → red. (agent:KASRA still equals kasra — reasoned in code, undocumented for operators: P2. Non-iterable value raises raw TypeError: P3.)
  • _NoRedirect: builder test kills the mutation; live redirector probe: shipped refuses 302, no secret leak; mutated leaks.
  • M7 / M12 / M17: all red now.
  • Inspector: stranded_notifications() + startup warning + mupot_gateway_status tool read the real outbox; mutations red. P2: tool not in plugin.yaml provides_tools; report-only with no operator action documented; _live_adapter never cleared on disconnect (P3).
  • No regressions: M15/M16/M19/M20/M22/M2 red; mupot_operator.py, telegram_control.py, profile_scope.py, routine_events.py byte-identical to 3cc6f297.

Also

__init__.py:160 legacy _maybe_start_inbox_stream still injects unfenced at role="user" — outside this diff, mutually exclusive with native, but the fence is not yet a class fix (P2). source_id is unfenced in the header and _SOURCE_ID_RE permits backticks — not attacker-reachable (server ids are randomUUID) (P3).

Minimum to clear

  1. Escape by run-length (neutralise every backtick run ≥3, or a delimiter the body cannot contain), test parameterised over 3–6 backticks asserting even run parity; fix docstring + evidence doc.
  2. Gate at the injection choke point (message_injector call / _flush_notifications / _handle_routine_event), and do not inbox_ack while paused; test with the real sentinel.
  3. Log the ImportError fail-open at WARNING; rewrite or delete the vacuous test.
  4. Scope the allow_from comment to the internal path; document allowed_agents semantics in README/runbook/example; declare mupot_gateway_status in plugin.yaml.

Mumega Operator and others added 2 commits September 14, 2026 17:42
… e-stop, honest tests

Second gate-BLOCK repair on this PR (kasra-review re-gate, 2026-09-14). Round 1's
fixes were each individually plausible but each missed the actual defect class;
this round fixes the class, not the reported repro.

P0-1 (fence escape regenerates its own delimiter): the prior
`text.replace("```", "`​``")` is a left-to-right, non-overlapping replace, so
any backtick run whose length is not a multiple of 3 (4, 5, 6, 9, ANSI-wrapped or
not) left a leftover backtick that recombined with the replacement into a fresh
literal run of 3 -- regenerating the exact delimiter it was meant to escape.
Fixed by escaping every backtick character individually (a zero-width space after
each one), which provably leaves no run of even 2 backticks anywhere in the body,
independent of run length or surrounding CR/LF/ZWSP/ANSI content. Also fixed the
docstring, which asserted the old, false property, and the legacy
`_maybe_start_inbox_stream` injector in __init__.py now routes through the same
escaping primitive instead of injecting unfenced text (one predicate, not two).

P0-2 (e-stop enforced on the named path, not the sink): round 1 added
_estop_engaged() to MupotAdapter._deliver only. _flush_notifications (run every
poll BEFORE the inbox lease) and _handle_routine_event both reach the message
injector and an irreversible source ACK without ever going through _deliver, so
`hermes pause` did not stop either path. Fixed at the actual choke points: the one
call site in notifications.flush() that invokes the injector, plus the top of
_handle_routine_event and _handle_ack_envelope (before any state mutation, mirror
of _deliver's existing guard) so every present and future producer inherits the
gate structurally. Also corrected the adjacent comment's false global claim
("allow_from is read nowhere in gateway/*.py") to the true, scoped claim (it is
read on Hermes's non-internal auth path, which internal=True skips today), and
renamed the test that cemented the false claim.

P1 (vacuous e-stop-unimportable test): the old test deleted agent.estop from
sys.modules in the NATIVE suite, where it is genuinely importable, so the delete
just forced a successful re-import. Rewritten to force a real ImportError via
builtins.__import__, and the fail-open branch now logs a WARNING once (asserted
in the test) instead of failing open silently.

Hygiene: allowed_agents now rejects a non-list/non-string value with a clear
config error instead of a raw TypeError; _live_adapter is cleared on
disconnect so mupot_gateway_status can't report a dead adapter's stale state;
mupot_gateway_status is declared in plugin.yaml's provides_tools; README, the
onboarding runbook, and the example config document allowed_agents
normalization and what to do with a stranded notification.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Round-2 gate-BLOCK repair evidence: what was actually wrong on head ab2b9d8,
the class-fix shape chosen, the 10-row mutation table executed this session
(all RED-when-mutated / GREEN-when-restored, verified not asserted), and the
real test.sh/test-native.sh counts (240+12+27 / 302). States plainly what was
not independently re-verified this round, including an open question about
_poll_loop's interaction with an estop-deferred message that pre-dates this
round and was not introduced by it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra re-gate #2 — head 936baa61BLOCK (fence fixed; e-stop fix trades a leak for an outage)

Isolated sandbox, real pinned Hermes, 21 mutations (18 red; 3 survivors pre-existing at base too). Counts reproduced: 240+12+27/27; native 302/302.

CONFIRMED-FIXED

  • Fence: escape-by-character (notifications.py:158); max backtick run in body region = 1 across 15 payloads incl. 3–9 backticks, ANSI, CRLF, ZWSP-preloaded, ~~~, </pre>, opener/caveat verbatim, 20KB. Caveat always last. Mutations F1/F2 red.
  • ImportError branch: poison → red; warning once → red both ways.
  • Legacy _maybe_start_inbox_stream through the fence: L1/L2 red.
  • allowed_agents: bad types → ValueError, []/"" deny-all, absent → default. (P3: non-string list entries stringified silently.)
  • allow_from comment true and scoped; docs/plugin.yaml/runbook stranded-id procedure present.
  • No regression: mupot_operator.py, telegram_control.py, profile_scope.py, routine_events.py byte-identical to 3cc6f297; M1/M7/M12/M16/M17/M18 red.

P0 NEW — hermes pause durably quarantines the inbox (proven through the real _poll_loop with the real sentinel)

adapter.py:2012: after _process_leased_message, if message_id not in processed: raise _protocol_error(). All three e-stop gates (:2076 routine, :2164 ack-envelope, :2205 _deliver) return without marking processed → _quarantine_inbox_polling() (:1803) → lease_reconciliation persisted to state.json, poll task exits, connect() refuses at :1673, reconcile_inbox_polling() fails while paused (:1927). After hermes resume: 0 activations, reconnect refused, on all three paths. Falsifies the shipped comments at adapter.py:2087-2092 and notifications.py:426-430. Base-vs-head: base leaked while paused but stayed live; head is safe but bricked, and the trigger widened to any inbound message.
Also still consuming while paused: adapter.py:2035 (routine event with routine_events_enabled:false → quarantined + acked) and :2052 (non-allowlisted sender → DLQ + acked).

P1 NEW — transport branch unfenced, mirrored as role="assistant"

flush() fences only the activation branch. notifications.py:548 deliver_text(target, notice["text"]) and :552 mirror_text(...) ship the raw body; :117-119 mirror_to_session passes no role, Hermes defaults to assistant — Hermes's own gateway/mirror.py:34-38 says non-agent text must be role="user" or it replays as a real agent turn. Executed: attacker body reaches the transcript raw, no fence, no caveat, as the agent's prior turn.

Minimum to clear

  1. E-stop must be a pause, not a protocol error: gate BEFORE lease in the poll loop (don't lease while paused), or on pause release/let the lease expire and continue without _protocol_error, never persist quarantine for a pause; gate :2035/:2052 consumes too. Prove through the real poll loop: paused → nothing leased/acked/injected, poll task alive; resume → delivery resumes, no duplicate, no reconciliation step.
  2. One fence for every branch of flush() (activation, deliver_text, mirror_text) and mirror_to_session(..., role="user"). Mutate each branch → red.
  3. Fix the two falsified comments. (P3: _SOURCE_ID_RE admits backticks; server ids are UUIDs so not reachable.)

Mumega Operator and others added 2 commits September 14, 2026 19:01
Round-3 re-gate #2 fix, two classes:

P0 -- a pause is a temporal condition, never a state transition. Round-2's
_estop_engaged() gates in _deliver/_handle_routine_event/_handle_ack_envelope
returned bare on pause, which _poll_loop then read as "message not marked
processed" and answered with _protocol_error() -> _quarantine_inbox_polling(),
a durable, connect()-refusing state that survives the pause. Fix: (a) check
the sentinel at the top of every poll iteration, before inbox_lease -- nothing
leased/acked/injected/written while paused, which also keeps the
routine_events_disabled-quarantine and sender-policy-DLQ branches inside
_process_leased_message from firing (neither can run without a lease first);
(b) the three gates now raise a new _EstopDeferred instead of returning bare,
so _poll_loop (and reconcile_inbox_polling's own copy of the same pattern) can
release the lease and continue without ever calling _protocol_error() or
_quarantine_inbox_polling() for a mid-message race. Corrected the two comments
that had gone false describing the old, quarantining behavior.

P1 -- untrusted notice text was fenced only in flush()'s activation branch;
deliver_text (Telegram) and mirror_text (conversation mirror) shipped
notice["text"] raw, and mirror_to_session's default role="assistant" made a
relayed remote notice replay as a genuine agent turn (Hermes's own
gateway/mirror.py:34-38: non-agent text must be role="user"). Fix: build one
fenced+caveated string once per notice, before any branch, consumed
identically by the activation event, deliver_text, and mirror_text; pass
role="user" to mirror_to_session.

P3: tightened routine_events._SOURCE_ID_RE to exclude backtick/[/] (defense in
depth, not attacker-reachable today); allowed_agents list entries that are not
strings now raise ValueError instead of silently stringifying.

Tests: real-poll-loop tests with the actual agent/estop.py sentinel (not a
faked is_engaged) proving pre-lease pause and mid-message defer for both the
Routine and peer _deliver paths, plus a real-Telegram-stub + real-SQLite-mirror
test proving the shared fenced string and role="user". scripts/test.sh 240 + 12
subtests + 27/27 unittest (unchanged). scripts/test-native.sh 319 passed (was
302, +17 new).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
21 mutations executed for real (temporary edit -> pytest run -> git checkout
restore -> clean status) against commit 40f8a10: both fencing guards (F1/F2)
plus the mirror-role guard, the full pause/liveness class (pre-lease check,
the exact "_protocol_error restored on the pause path" mutation the brief
named, and all 4 individual e-stop call-site gates), the P1 vacuous-test
guard, the peer allowlist no-regression guard, all allowed_agents validation
guards including the new P3 fix, the new _SOURCE_ID_RE P3 guard, and the four
round-1 guards named explicitly in the brief (M7/M12/M17/M18) each
individually re-mutated. scripts/test.sh 240 + 12 subtests + 27/27 unittest
(unchanged). scripts/test-native.sh 319 passed, 0 failed (was 302, +17).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra re-gate #3 — head 58602a83BLOCK (outage fixed, fence closed; e-stop is a lease gate, not a work gate)

Isolated sandbox, real pinned Hermes, real agent/estop.py sentinel through the real _poll_loop; 26 mutations (23 red, 3 pre-existing survivors + 1 new). Counts: 240+12, 27/27; native 319/319. CI 2/2 on head.

CONFIRMED-FIXED

  • Pause outage (P0): pre-lease pause → 0 leases/acks/injections, poll alive, no state file; mid-lease pause on _deliver/routine/ack-envelope → lease fenced locally, no _protocol_error, no quarantine; resume → exactly one delivery. reconcile_inbox_polling() while paused → False, marker preserved. Base-vs-head: all three paths went from "durably bricked" to live.
  • Fence per branch (P1): one fenced_text at notifications.py:439; deliver_text, mirror_text (role="user"), activation all consume it; attacker body through a real Telegram stub + real SQLite mirror → fenced, caveat last, no run ≥2. FD/FM/FA/FR/FC mutations red.
  • _SOURCE_ID_RE tightened; allowed_agents per-entry non-string → ValueError. Byte-identical: mupot_operator.py, telegram_control.py, profile_scope.py vs 3cc6f297.

P1 — hermes pause does not stop egress or replay-consume (executed on both refs)

adapter.py:2035 gates statement #4 of the poll body. Statements #1#3 run unconditionally every tick while paused: _replay_routine_events (:2214, via :1986) → inbox_ack + enqueue + processed; _replay_reply_outbox (:1619, via :2002) → peer send + inbox_lease_ack + commit; _flush_notifications (:2459, via :2017) → flush() non-activation branch (notifications.py:572-590) → Telegram sendMessage + transcript row. Comment at adapter.py:2028-2030 ("nothing is leased, nothing is acked, and no durable state is written for the duration of the pause") is false. Not a regression vs base, but the kill switch does not kill.

P2 — mid-lease race still consumes on two dispatcher branches

adapter.py:2113-2119 (routine_events_enabled:false → quarantine + ack + commit) and :2131-2135 (non-allowlisted → DLQ + ack + commit), plus :2120-2122 re-ack: no _estop_engaged() guard, never raise _EstopDeferred. Executed: acked + processed while paused. Comment at :2030-2034 claims otherwise. Named in re-gate #2's minimum-to-clear.

P2 — E6 survivor

reconcile_inbox_polling's new _EstopDeferred handler (adapter.py:1955-1971): mutating it to clear the quarantine and return True leaves 319/319 + plain green. Contract unpinned.

WARN

  • Lease release is expiry-only (:2088 clears a local fence; no server release); redelivery latency ≤ lease_seconds (:969-970, up to 3600s). No fake models server lease invisibility, so "delivers once after resume" is proven for the local loop only.
  • _replay_routine_events (except Exception → permanent fatal) and _replay_reply_outbox (except Exception → warn) will misclassify an _EstopDeferred if the fix raises one there — the exact fix shape.
  • _estop_engaged() fails safe (True) on stat error with only an INFO log.

Minimum to clear

  1. One _estop_engaged() check at the TOP of the poll iteration gating the ENTIRE iteration (replays + flush + lease), and one choke point on every consume/egress primitive (_ack_expected/_commit, peer send, deliver_text, mirror_text, inject) that refuses while paused — so the mid-lease branches and any future stage inherit it. Handle _EstopDeferred explicitly before the broad except Exception in both replay functions.
  2. Pin reconcile_inbox_polling's pause contract (E6).
  3. Fix the two false comments. Document expiry-only lease release and the latency bound.

Mumega Operator and others added 2 commits September 14, 2026 20:05
…rimitive on e-stop

kasra-review re-gate #3 (2026-09-14, PR #6): rounds 1-3 each fixed a NAMED
site (_deliver, _handle_routine_event, _handle_ack_envelope, the fence
escape) and the next re-gate found the next ungated site -- most recently,
_poll_loop checked _estop_engaged() only right before inbox_lease, leaving
_replay_routine_events/_replay_reply_outbox/_flush_notifications running
unconditionally every tick while paused (real ACKs, real peer sends, real
Telegram/mirror egress), plus three _process_leased_message branches
(routine-events-disabled quarantine, already-processed re-ack, sender-policy
DLQ) that consumed a message without ever reaching a gated function.

Round 4 closes the CLASS instead of the next site:

- _poll_loop checks _estop_engaged() as its very first statement: a paused
  tick does nothing but sleep/backoff, full stop.
- _process_leased_message itself gates at its top, before dispatching to any
  branch -- closes the three previously-ungated branches structurally.
- A new shared choke point, _refuse_ack_if_estop_engaged(), gates both
  _ack_expected and _ack_persisted_ownership -- the only two functions that
  ever call inbox_lease_ack/inbox_ack; every _commit() call site is reached
  only immediately after one of these two succeeds, so this transitively
  gates the commit too.
- _transmit_final_reply (the peer `send` MCP call, used by both
  _replay_reply_outbox and the live send() interim path) gates before the
  network call.
- notifications.flush() gates each of its three sinks (activation injector,
  deliver_text, mirror_text) independently, not just the activation branch.
- _EstopDeferred is now caught explicitly in _poll_loop's wrapping of
  _replay_routine_events/_replay_reply_outbox, before their broad
  except-Exception clauses, so a mid-iteration pause is never misclassified
  as a routine-event reconciliation failure or a reply-replay warning.

Ported kasra-review's own independent execution drivers (not shipped with
the PR) into permanent regression tests: tests/native/test_estop_lease_gate.py
(whole-iteration gate across 5 message classes, pre-lease and mid-lease),
test_estop_egress_gate.py (outbox egress during pause + the
reconcile_inbox_polling pause contract kasra-review found unpinned),
test_estop_replay_gate.py (adapted for _EstopDeferred now propagating from
direct calls to the replay functions), plus two new deliver_text/mirror_text
choke-point tests in test_notifications.py mirroring the existing activation
choke-point test's pattern.

Documents the exhaustive gated-primitive list in _EstopDeferred's and
_estop_engaged's docstrings (replacing the stale per-round named-function
lists), and adds a runbook section on the e-stop contract: lease release is
expiry-only (redelivery latency bounded by lease_seconds, up to 3600s, not
by how fast the pause lifts), and reconcile_inbox_polling() returns False
without acking or clearing the quarantine marker while paused.

scripts/test.sh: 240 passed + 12 subtests, 27/27 unittest (unchanged).
scripts/test-native.sh: 336 passed, 0 failed (was 319, +17 new).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
…ngthen ported tests

- Fixed a vacuous measurement carried over from kasra-review's own driver:
  test_estop_lease_gate.py read state["routine_quarantine"], which never
  matched the real routine_event_quarantine key, so it silently reported 0
  regardless of what the code did. Fixed the key name and added assertions
  in both test_pre_lease_pause and test_mid_message_pause so the
  _process_leased_message top-of-function gate is independently pinned
  rather than only appearing redundant with the ack choke point.
- Added test_live_interim_send_defers_without_transmitting_while_paused
  (send()'s `if interim:` branch had no test coverage at all).
- Added two direct tests pinning _poll_loop's own except-clause wiring for
  _replay_routine_events and _replay_reply_outbox (isolated from the
  realistic, timing-dependent mid-iteration race already covered
  end-to-end elsewhere in this file and in test_estop_lease_gate.py).
- Full round-4 mutation table (10 new-guard rows, all executed for real:
  temp edit -> red -> git checkout -- restore -> git status clean) plus 4
  spot-checks confirming round 1-3 guards (M1, F1, M17, E3) are unchanged.

scripts/test.sh: 240 passed + 12 subtests, 27/27 unittest (unchanged).
scripts/test-native.sh: 339 passed, 0 failed (was 336, +3 new).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra re-gate #4 — head 709c0d0eAMBER → merge-defensible (e-stop class closed by execution)

Isolated sandbox byte-checked against the head; real pinned Hermes; real sentinel through the real _poll_loop; 35 mutations (32 red; M15/M20/M22 pre-existing survivors since round 1, untouched by this PR). Counts reproduced: 240+12, 27/27; native 339/339. CI 2/2 honest.

Liveness matrix (head vs base 58602a8)

All 5 message classes × {pre-lease, mid-lease}, outbox-before-pause (direct + real poll loop), mirror-only race, reply-outbox replay, routine-event replay, interim send, reconcile-while-paused: head 18/18 PASS (zero ack/send/deliver/mirror/inject, no durable state, poll alive; resume → exactly-once, no reconciliation). Base fails 8 of those cells. E6 now red.

Choke totality

12 gate sites, exactly the docstring's list; every inbox_lease_ack/inbox_ack/peer send/deliver_text/mirror_text/activate is reached only through one. Human /approve while paused is governed by Hermes's own _hm_estop_turn_allowed (control traffic passes by design) — correctly out of plugin scope.

Follow-ups (none a security hole)

  • P2 __init__.py:160 legacy inbox_watch inject path has the fence but no _estop_engaged() — config-exclusive with native, but the docstring claims plugin-wide.
  • P2 Observability: _estop_engaged() fail-safe (adapter.py:668) returns True with zero log records, and 4 of 12 choke points refuse silently; a pause-refused final reply surfaces as response send failed … error=src-final with no mention of pause.
  • P3 DLQ row duplicated across a pause (:2268-2272, :2402-2406): write → deferral → redelivery → second append; bounded by dlq[-100:], local only. Falsifies the :2196 "nothing to unwind" comment.
  • P3 Three inaccurate comments: _EstopDeferred "regardless of which function calls it" (legacy path) and "or making any network call" (read-only inbox_consumer_status/inbox_lease_reconcile still issued); notifications.py:584 "stays exactly as it was" (RetryLater writes attempts/retry_at).
  • P3 Test quality: tests/native/test_estop_lease_gate.py:238 reads keys that don't exist (notifications/outbox), so outbox_paused is always 0; sent_paused/injections_paused/pending_paused measured, never asserted; test_mid_message_pause lacks the ack_calls_paused == [] assertion.

Not verified

Server-side lease invisibility (fake re-leases immediately; ≤3600s redelivery ceiling read, not exercised); scripts/test-integration.sh (in no CI job); legacy SSE mode end-to-end; ruff/mypy; multi-process shared state_path.

Mumega Operator and others added 2 commits September 14, 2026 21:30
… dedupe DLQ

Round 5 (kasra-review re-gate #4 AMBER, 2026-09-14) small tidy on the
e-stop class:

- __init__.py's legacy _maybe_start_inbox_stream deliver() had the
  untrusted-body fence but no _estop_engaged() check, even though
  _EstopDeferred's docstring claimed plugin-wide coverage. Gated it (this
  legacy stream has no deferral/redelivery surface, so a refusal drops the
  batch -- log once per pause window, not once per dropped batch). Fails
  open with a one-time warning when mupot_gateway.adapter (and its
  gateway.* transitive imports) is not importable, matching
  _estop_engaged()'s own agent.estop fallback -- keeps the plain
  scripts/test.sh suite working standalone.
- adapter.py's _estop_engaged() fail-safe except now warns once per
  distinct failure window (not once per call) instead of swallowing
  silently. Four of the plugin's 12 gate sites that refused silently
  (_refuse_ack_if_estop_engaged, _transmit_final_reply,
  _process_leased_message's own top gate, send()'s interim path) now log
  once per pause window via a shared _note_estop_pause_once helper. A
  pause-refused SendResult now names "estop_paused" in its error string.
- DLQ append (sender_policy branch in _process_leased_message,
  invalid_ack_envelope branch in _handle_ack_envelope) is now idempotent
  by message id: a pause landing between the DLQ write and the ack no
  longer produces a duplicate row on redelivery.
- Comment/docstring accuracy: _EstopDeferred's docstring no longer
  overclaims "regardless of which function calls it" (now precise: every
  inject/consume/egress primitive in this plugin, as of this round) and
  narrows "or making any network call" to consuming/egress calls
  (read-only inbox_consumer_status/inbox_lease_reconcile are exempt).
  notifications.py's deliver_text choke point comment no longer claims
  the notice "stays exactly as it was" during a pause -- RetryLater's own
  except-handler does write attempts/retry_at bookkeeping, which is
  harmless (only feeds an already-capped exponential backoff, no other
  cap it could violate).
- tests/native/test_estop_lease_gate.py read wrong/synthetic outbox keys
  (always a silent 0); switched to the real notification_outbox/
  reply_outbox keys and added the previously-measured-but-unasserted
  sent_paused/injections_paused/pending_paused assertions, plus
  ack_calls_paused to test_mid_message_pause.

New tests/native/test_estop_observability.py isolates each of the 4
newly-logged choke points, the fail-safe warning window, and the DLQ
idempotency fix with direct mutation-provable assertions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
Round 5 (kasra-review re-gate #4 AMBER) evidence: real mutation-testing
results for the 7 new guards (legacy inbox-stream gate, 4 silent
choke-point logs + SendResult.error naming, fail-safe warning window,
DLQ idempotency), an honest mutation-load-bearing analysis of item 5's
new test assertions (pending_paused proven uniquely load-bearing;
sent_paused/injections_paused documented as subsumed/unreachable within
this harness rather than fabricated as unique), spot-checks of prior
rounds' M1/E6/F1 guards (still red), a substitution note for the
unrecoverable N1/N3/N6 mutation IDs, real test counts (241+12/27,
native 346/346), and the discrepancy reconciliation against the actual
re-gate #4 comment text. Runbook gains a line on the legacy inbox
stream's drop-not-defer pause behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra re-gate #5 — head 917ae245AMBER, merge-defensible (tidy closes 4 of 5 round-4 follow-ups)

Isolated sandbox byte-verified before and after; real pinned Hermes + real sentinel; 20 mutations (19 red, 1 survivor). Counts reproduced: 241+12, 27/27; native 346/346 (13 files). CI honest.

CONFIRMED: legacy deliver() e-stop gate (__init__.py:225, D1 red; dropped-not-deferred while paused, documented in code + runbook); once-per-window logging reset in the check path for the adapter (adapter.py:728-729); SendResult.error carries estop_paused; DLQ dedup on both branches works; test-quality keys real; ack_calls_paused == [] present; comments at :2196 and notifications.py:585 accurate on substance; byte-identical: mupot_operator.py, telegram_control.py, profile_scope.py vs 3cc6f297, routine_events.py, lease_ownership.py vs 58602a83.

F1 (P2, NEW) __init__.py:202 reaches the sentinel via from .mupot_gateway.adapter import _estop_engaged — a module-level import httpx + gateway chain — and fails OPEN after one process-lifetime WARNING. Executed: sentinel engaged + import broken → attacker body injected into the human session. Fix: import agent.estop.is_engaged directly; missing e-stop module must fail closed.
F2 (P2) adapter.py:154-157 _EstopDeferred docstring now claims "every inject/consume/egress primitive in this plugin"; mupot_operator.py send (:587) and inbox consume=True (:596) and __init__.py:129 are ungated. Scope must be stated, not asserted.
F3 (P3) legacy once-per-window flag resets only after a successful delivery (__init__.py:234): pause→drop→resume(no batch)→pause→drop → 0 records. Gate holds; silence only.
F4 (P3) invalid_ack_envelope DLQ dedup (adapter.py:2515-2520) unpinned: D9 survived 346/346 (probe: 2 rows under D9, 1 on head). Evidence table lists the sender-policy twin only.
F5/F6 (P3) refused final replies now log 1 INFO per window and no WARNING (old WARNING at :2843 unreachable); notifications.py:589-591 lists 2 of 3 writes.

Not verified: server-side lease invisibility; scripts/test-integration.sh (in no CI job); legacy SSE end-to-end; ruff/mypy; multi-process state_path; whether Hermes logs SendResult.error operator-visibly; F1 under a genuinely httpx-less install (ImportError simulated).

Micro-round dispatched for F1–F6.

…e-stop gate

F1: legacy inbox-stream deliver() now reaches agent.estop.is_engaged directly
instead of through mupot_gateway.adapter's httpx/gateway.config chain. Fails
OPEN only when the whole `agent` package is absent (the plain, non-native
scripts/test.sh suite); fails CLOSED (drops the batch) when `agent` is
present but `agent.estop` specifically is not, and fails SAFE on a genuine
is_engaged() exception -- a missing e-stop module must not read as "not
paused".

F3: the same deliver() closure now compares pause IDENTITY (agent.estop's
own engaged_at) instead of a "logged until a disengage is observed" boolean,
so a new pause with no intervening batch still logs (deliver() only runs on
an actual batch -- event-driven SSE, no idle tick -- so the old boolean
could go a full pause window without ever seeing the prior disengage).

F2: _EstopDeferred's docstring no longer overclaims "every inject/consume/
egress primitive in this plugin"; it now names the exact two native-receive
surfaces it covers and explicitly lists what is NOT gated and why
(mupot_operator.py's send/inbox tool calls, by parity with Hermes not
stopping a running session's tool calls; the provisioner reminder inject;
/approve control traffic, which is Hermes's own concern).

F4: adds the invalid_ack_envelope DLQ idempotency test the sender-policy
twin already had but this branch's identical dedup guard did not -- D9
survived the full suite; mutation-verified red/green.

F5/F6: send()'s except _EstopDeferred now also emits one WARNING per pause
window naming the request id for a refused FINAL reply specifically (the
one case where a human/peer gets no answer at all), in addition to the
existing choke-point INFO; notifications.py's RetryLater comment now lists
all three bookkeeping writes (attempts, retry_at, last_error).

Real counts: scripts/test.sh 241 passed + 12 subtests, unittest 27/27;
scripts/test-native.sh 350/350 (up from 346, +4 new: F1, F3, F4, F5/F6).
Spot-checked mutations red->green: D1, D8, D9, N1, F1(fence), M1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwCzY7SU9p9A7mRWWpgqT
@servathadi

Copy link
Copy Markdown
Contributor Author

Kasra micro-round — head f309211 (all 5 re-gate #5 findings closed)

Fresh isolated clone, pinned Hermes at ../hermes-runtime (read-only). Commit before mutation testing, per protocol.

F1 (P2): __init__.py's legacy deliver() now imports agent.estop.is_engaged/get_state directly instead of via mupot_gateway.adapter._estop_engaged (drops the httpx/gateway.config dependency chain). Two ImportError cases distinguished: whole agent package absent (plain scripts/test.sh suite — fails OPEN, matches native gateway's own precedent) vs. agent present but agent.estop specifically missing (fails CLOSED, drops the batch, warns once). A genuine is_engaged() exception fails SAFE (treated as paused).

F3 (P3): same closure now compares pause identity (agent.estop's own engaged_at) instead of a "logged until disengage observed" boolean — fixes the case where deliver() (event-driven, no idle tick) never runs during a resumed window, so a brand-new pause's drop no longer looks like a continuation of the prior one.

F4 (P3): added test_invalid_ack_envelope_dlq_append_is_idempotent_across_a_pause_before_the_ack (D9), mirroring the sender-policy twin (D8). Mutation-verified red→green.

F2 (P3): _EstopDeferred's docstring no longer claims "every inject/consume/egress primitive in this plugin" — now names the two native-receive surfaces it actually covers and explicitly lists what's out of scope and why (mupot_operator.py's send/inbox consume=True, by parity with Hermes not stopping a running session's tool calls; the provisioner reminder inject; /approve control traffic, Hermes's own concern).

F5/F6 (P3): send()'s except _EstopDeferred now also emits one WARNING per pause window naming the request id for a refused final reply specifically, alongside the existing choke-point INFO. notifications.py's RetryLater comment now lists all three bookkeeping writes (attempts, retry_at, last_error).

Real counts: scripts/test.sh 241 passed + 12 subtests, unittest 27/27, 0 failures. scripts/test-native.sh (fresh HERMES_SOURCE checkout) 350/350, 0 failed (up from 346 — +4: F1, F3, F4, F5/F6).

Mutation spot-checks (temp edit → red → restore → green, each verified for real):

  • D1 (legacy inbox-stream estop gate if engaged:if False:) — RED (test_legacy_inbox_stream_deliver_refuses_inject_while_real_estop_engaged), GREEN restored
  • D8 (sender-policy DLQ dedup guard removed) — RED (2 rows instead of 1), GREEN restored
  • D9 (invalid_ack_envelope DLQ dedup guard removed, this round's new test) — RED, GREEN restored
  • N1 (substitute: notification activation-injector choke if _estop_engaged():if False:) — RED (test_flush_real_estop_sentinel_blocks_activation_at_the_single_choke_point), GREEN restored
  • F1(fence) (round-2 fence escape neutered: safe = text) — RED, 8 tests failed exactly as documented in round 2's table, GREEN restored
  • M1 (peer allowlist if should_accept_message(...):if True:) — RED (test_unlisted_sender_is_quarantined_never_delivered, attacker body reached the handler), GREEN restored

git diff --stat empty after each restore, confirmed before the final commit.

CI on f309211: plugin SUCCESS, native-gateway SUCCESS (both observed terminal via ci-watch, not inferred).

Diff: __init__.py +/-, mupot_gateway/adapter.py, mupot_gateway/notifications.py, tests/native/test_estop_egress_gate.py, tests/native/test_estop_observability.py, tests/test_native_registration.py (updated 2 existing plain-suite tests whose assertions targeted the pre-F1 import path/message text). 506 insertions, 68 deletions across 6 files.

Not done / could not verify (unchanged from prior rounds): scripts/test-integration.sh (no MUPOT_SERVER_SOURCE), ruff/mypy full sweeps, server-side lease invisibility, multi-process shared state_path.

Branch only, no merge/deploy performed.

@servathadi
servathadi marked this pull request as ready for review September 14, 2026 22:27
@servathadi
servathadi merged commit 332119b into master Sep 14, 2026
2 checks passed
@servathadi

Copy link
Copy Markdown
Contributor Author

Live canary — PASS (Kasra, 2026-09-14 22:30Z)

  • Plugin master 332119b (0.4.0) installed on the KayHermes gateway (hermes-gateway-kayhermes.service, new PID 3724772, single full restart 22:28:06Z); server at mupot 49a344aa.
  • Governed path: mupot send → message abd28d25 (seq 4641) → plugin leased (state pending, delivery_attempts: 1, lease expiry 22:35) → model turn → inbox_lease_ackreply_outbox: complete, processed, pending: null, zero stranded.
  • ACK received in Kasra's inbox (seq 4642, in_reply_to correct): plugin_version=0.4.0 plugin_commit=332119b… native_receive=yes lease_count_observed=1 delivery_attempts=1; KayHermes performed no grant/gate/deploy/restart/credential change.
  • Correlation as expected: one lease, one ack, one activation, no duplicates.

Live defect found by the canary: mupot_gateway_status dispatch → TypeError: gateway_status() got an unexpected keyword argument 'task_id' (Hermes registry passes extra kwargs). Hotfix PR in progress.

Not yet wired: telegram_control_enabled + IM_WEBHOOK_SECRET on the profile (the /approve relay). Next step.

@servathadi

Copy link
Copy Markdown
Contributor Author

Telegram control wired (Kasra, 2026-09-14 22:33Z)

  • IM_WEBHOOK_SECRET rotated on the mupot worker (no prior holder existed on the gateway host; the only server consumer is /im/webhook), same value written to the kayhermes profile env (0600), sha256[:12] = dbc59abc1076 on both sides.
  • Profile config: telegram_control_enabled: true, telegram_control_webhook_secret_env: IM_WEBHOOK_SECRET under operator:; backups of .env and config.yaml kept alongside.
  • Gateway restarted (PID 3730815, 22:32:40Z), no config errors, poll loop alive.
  • Pair check: POST /im/webhook with the profile secret and a synthetic private-chat /needs from an unregistered user → 200 {"ok":true,"reply":"You're not registered with this workspace yet…"} (correct fence; 401 would have meant mismatch).

Remaining for the live pilot: mint one project invite for the owner, redeem via /start from Telegram, exercise /needs + /approve on a real human-wait. Hotfix for mupot_gateway_status (task_id kwarg) in progress.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f30921115e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mupot_gateway/adapter.py
or not _LEASE_ATTEMPT_ID_RE.fullmatch(attempt_id)
or result.get("attempt_id") != attempt_id
or result.get("state") not in _LEASE_ATTEMPT_STATES
or result.get("consumed") is not False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept consumed terminal receipts during reconciliation

When inbox_lease_ack reaches Mupot but its response is lost, the adapter quarantines the attempt and later passes the authoritative inbox_lease_reconcile result through this validator. A completed attempt is reported as state: "acked", consumed: true, but this unconditional check rejects it, so reconcile_inbox_polling() can never clear the quarantine after precisely the ambiguous-ACK failure it is intended to recover from; it also incorrectly accepts the contradictory acked/consumed:false combination. Validate consumed according to the attempt state so a terminal consumed receipt can complete recovery.

Useful? React with 👍 / 👎.

Comment thread mupot_operator.py
Comment on lines 355 to +357
def _invoke(self, action: str, args: JsonObject) -> JsonObject:
try:
token = self._read_token()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind protected actions to the credential that passed validation

For every non-status action, call() first validates an identity snapshot and then enters _invoke() again, where this line reads the secret afresh. If the profile credential rotates between those operations, the action can run with a token whose tenant, bound agent, role, and capabilities were never checked; the same gap exists between manager capability checks and the eventual manager action. Carry one token snapshot through the identity/capability checks and protected request, while allowing the next top-level call to observe rotation.

Useful? React with 👍 / 👎.

Comment on lines +25 to +27
if [ ! -f "$HERMES_SOURCE/scripts/run_tests.sh" ]; then
printf '%s\n' 'HERMES_SOURCE does not contain the canonical Hermes test runner' >&2
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the pinned Hermes checkout before integration

In scripts/test-integration.sh, the Mupot checkout is checked for the exact expected commit and a clean tracked tree, but the supplied Hermes checkout is accepted after only testing for one script. Consequently this acceptance test can pass against an arbitrary or locally modified Hermes runtime even though the documented evidence attributes the result to pinned revision 233757037df1f03f9fe1cfddc097acd5ad7f7510; add equivalent HEAD and cleanliness checks before running Vitest.

Useful? React with 👍 / 👎.

Comment thread telegram_control.py
Comment on lines +198 to +200
request = Request(
urljoin(settings.base_url.rstrip("/") + "/", "/im/webhook"),
data=body,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve an accepted base URL path for Telegram webhooks

TelegramControlSettings.validate() accepts HTTPS base URLs containing a path, but the leading slash in the second urljoin argument discards that path. With a valid configuration such as https://example.invalid/mupot, every command is therefore posted to https://example.invalid/im/webhook instead of https://example.invalid/mupot/im/webhook, making project control fail and potentially sending the webhook-secret header to an unrelated endpoint on the same host. Join a relative im/webhook segment or reject non-root base paths during validation.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T22:38:59.039496Z f309211 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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