Skip to content

Harden gateway command approvals with durable atomic approval store - #41427

Open
miclloyd wants to merge 16 commits into
NousResearch:mainfrom
miclloyd:michael/gateway-approval-hardening
Open

Harden gateway command approvals with durable atomic approval store#41427
miclloyd wants to merge 16 commits into
NousResearch:mainfrom
miclloyd:michael/gateway-approval-hardening

Conversation

@miclloyd

@miclloyd miclloyd commented Jun 7, 2026

Copy link
Copy Markdown

Summary

Replaces the in-memory dict + threading.Lock approval storage in
tools/approval.py with a SQLite-backed durable store. The change closes
a class of safety gaps where dangerous-command approvals could be (a)
lost on gateway restart, (b) raced across processes, (c) silently
downgraded by config drift between proposal and execution, (d)
approved without the user reading the specific id, or (e) audited as
"approved" when post-consume guards actually blocked execution.

What changes

  • SqliteApprovalStore as the production default. Table
    gateway_approvals inside the existing Hermes state.db. Atomic
    consume via BEGIN IMMEDIATE + UPDATE … WHERE status='pending' … RETURNING. Idempotent schema with PRAGMA-checked ALTER TABLE for
    pre-existing DBs. Wired in GatewayRunner.__init__. Boot-time
    wiring failure marks the store init-failed; subsequent gateway-
    context approvals fail closed (no silent degradation to legacy
    in-memory FIFO).
  • /approve <id> is the only accepted form. No FIFO. No
    /approve all. Even with exactly one pending approval, no-id refuses.
    Cross-session id-spoofing rejected via persisted session_key check.
  • Pinned policy validated at proposal construction.
    ApprovalProposal.__post_init__ raises ValueError on malformed
    payloads — empty risk_reason, invalid risk_level,
    high-risk-without-default-deny, etc.
  • Stricter runtime reclassification fails closed. Phase 3 guard
    re-classifies the command at execution-thread wake-up. If the live
    classifier ranks it higher than the pinned risk, the consumed
    approval is overridden to deny.
  • Audit-distinct execution outcome. New columns
    execution_status / execution_reason / execution_recorded_at
    on gateway_approvals. status='consumed' records that the user
    clicked /approve; execution_status records whether the command
    actually ran (executed), was blocked by Phase 3 / orphan / etc
    (blocked_after_consume), or never reached execution because the
    user denied (not_started). Retrospective audit can answer
    "did this approval lead to an execution?" unambiguously.
  • Orphan-approve surfaces NOT-executed UX. When /approve <id>
    consumes a store row but no live waiter exists (gateway restart,
    agent run finished), the handler returns
    gateway.approve.orphan_consumed ("Approval recorded, but the
    original command session is no longer active. The command was NOT
    executed.") instead of the misleading "✅ Command approved" message.
  • Submit-failure fail-closed. When store.submit raises (DB
    locked, disk full, validation refused), gateway returns BLOCKED
    with "approval store unavailable" message. No _ApprovalEntry
    queued, no notify_cb invoked, no legacy fallback.
  • High-risk UX hard stop. risk_level == 'high' prompts include
    🚨 HIGH RISK, Default: DENY, exact command, cwd/backend
    context, pinned risk reason, the approval id, and either an inline
    diff/summary or an explicit
    NOT AVAILABLE — approve only if you have independently reviewed
    warning.
  • Stub/fake test contract. ApprovalStoreContract mixin in
    tests/tools/approval_store_contract.py parameterised over both
    InMemoryApprovalStore (xfail-strict on persistence gaps, by
    design) and SqliteApprovalStore (passes all). Future backends
    must satisfy the same contract to be usable.

Storage rationale

The previous module-level dict + threading.Lock storage gave
within-process atomicity but failed cross-process consume, lost state
on gateway restart, and had no audit trail. SQLite gives all of these
without a custom locking protocol — explicitly the spec required no
"best effort JSON + sidecar lockfile" approach.

Documentation: docs/security/gateway-approval-lifecycle.md.

Test results

Targeted approval/gateway suite: 312 passed, 3 xfailed in 8.62s

The 3 xfailed-strict are the documented in-memory backend contract
gaps (persistence + cross-instance atomicity + pinned-payload roundtrip
through a second store instance). They MUST remain xfailing — if they
start passing it means the in-memory backend grew unauthorised
persistence behavior. The SqliteApprovalStore passes the same three
tests.

Broad suite (pytest tests/ -k "approval or approve or gateway") has
unrelated optional-dependency failures (matrix/telegram extras not in
[dev]) and a handful of pre-existing test-isolation issues that pass
when run individually. Documented in HANDOFF.md in the local-only patch
artifact bundle.

Test plan (post-merge, before deploy)

  • /approve without id is rejected
  • dangerous command creates a proposal with a visible id
  • /approve with wrong id is rejected (and the real proposal stays
    pending)
  • /approve with correct id executes the command exactly once
  • gateway restart with pending approval → fail-closed behavior:
    /approve <id> post-restart consumes the row but returns
    orphan-message; no command runs; execution_status='blocked_after_consume'
  • store init failure at boot → subsequent dangerous-command approvals
    return BLOCKED with approval store unavailable message
  • Phase 3 runtime override: pin a low-risk classification, mutate
    classifier to high-risk, /approve → status='consumed' but
    execution_status='blocked_after_consume' with
    execution_reason='phase3_runtime_stricter'

Status

Draft PR. Self-review complete; two real-bugs found and fixed in the
audit/UX layer (see fix(audit) commit). Awaiting maintainer review.
Not yet deployed to any production gateway.

🤖 Generated with Claude Code

miclloyd added 15 commits June 7, 2026 13:57
Defines tools.approval_store.ApprovalStore protocol with frozen-dataclass
ApprovalProposal carrying all fields the security spec requires pinned at
proposal-creation time (risk_level, risk_reason, policy_decision,
policy_version, requires_explicit_approval, default_decision, diff_*,
display_text) plus lifecycle (status, consumed_at, consumed_by).

Adds tools.approval_store_memory.InMemoryApprovalStore as a thin
process-bound reference implementation — intentionally fails the
cross-instance contract; documented in module docstring.

tests/tools/approval_store_contract.py is a shared contract test mixin
parameterised over backend factories. Backend test files subclass and
provide make_factory().

tests/tools/test_approval_store_memory.py runs the full contract against
InMemoryApprovalStore. Result (verified): 7 passed, 3 xfailed(strict).

Tests that pass (single-process semantics InMemory does correctly):
  - exactly-once consume (same store instance)
  - 8 concurrent threads → 1 winner (threading.Lock holds)
  - expired/denied proposals not consumable
  - missing approval_id fails closed
  - bulk expire_due
  - duplicate submit raises ValueError

Tests that xfail strict (security gaps only transactional persistent
storage can satisfy — proof that the contract is meaningful):
  - persistence across store instances
  - cross-instance atomic consume
  - pinned-policy roundtrip via second store instance

A backend that satisfies all 10 — to be added in next commit
(SqliteApprovalStore backed by hermes_state.db) — closes the gap.

Phase 5 of hermes-gateway-approval-safety implementation plan.
Implements SqliteApprovalStore satisfying the full ApprovalStore
contract from the previous commit. Same gateway_approvals table lives
inside the existing hermes state.db (default ~/.hermes/state.db) but
with a self-contained CREATE TABLE IF NOT EXISTS bootstrap — no
coupling to hermes_state.SCHEMA_VERSION migrations.

Atomicity model:
- Every state transition uses BEGIN IMMEDIATE + UPDATE ... RETURNING.
- UPDATE WHERE status='pending' clause is the compare-and-set; if
  somebody else already transitioned the row, our UPDATE matches 0
  rows and we return None (fail closed, not fail loud).
- SQLite file-level locking serialises concurrent writers regardless
  of whether they live in the same process or in different processes.

Payload model:
- ApprovalProposal serialised to payload_json at submit time, never
  rewritten. Lifecycle (status, consumed_at, consumed_by) lives in
  dedicated columns. On read the original payload is deserialised and
  current lifecycle columns are overlaid — this preserves the
  pinned-policy invariant by construction since the pinned fields
  inside payload_json are immutable post-submit.

Fail-closed behavior:
- Corrupt payload_json raises ApprovalStoreError (vs. returning a
  half-constructed ApprovalProposal that the executor might run blind).
- Missing approval_id → None (no exception).
- Already consumed/denied/expired → None.
- Duplicate submit → ValueError (PRIMARY KEY collision surfaces
  cleanly).

Test results (verified):
- tests/tools/test_approval_store_sqlite.py: 12 passed, 0 failed.
- The 3 tests InMemoryApprovalStore xfail-strict (persistence,
  cross-instance atomic consume, pinned-policy roundtrip via a
  second store instance) all PASS against SqliteApprovalStore — that
  is the proof that the contract is satisfiable and that the gap
  documented by InMemory is closed by transactional storage.

NFS/SMB note: WAL is enabled via apply_wal_with_fallback (reused from
hermes_state) so the same journal_mode=DELETE fallback applies on
filesystems that can't host WAL.

Phase 2 of hermes-gateway-approval-safety implementation plan.
Wires tools.approval_store as a shadow-persistence layer underneath the
existing in-memory gateway approval flow. The in-memory _gateway_queues +
threading.Event mechanism still drives execution gating in this commit;
the durable store row is a parallel record that future commits will
promote to source-of-truth for /approve <id> consumption.

Changes to tools/approval.py:

- _ApprovalEntry gains an approval_id slot. When the store is configured
  the entry's id ties it to a persisted gateway_approvals row.
- Module-level injection hook: set_default_approval_store() / get_*.
  Production gateway init wires SqliteApprovalStore via this hook.
  Default is None, which preserves the legacy in-memory-only behavior
  (this keeps existing tests green).
- _await_gateway_decision now: generates approval_id via secrets.token_urlsafe,
  builds an ApprovalProposal with pinned metadata (command, risk_reason,
  policy_decision='needs_approval', default_decision='deny',
  requires_explicit_approval=True, expires_at=now+timeout), and submits
  it to the store BEFORE the notify callback fires. The approval_id is
  added to approval_data so the user-facing message can include it (the
  next commit will start using it for /approve <id>).
- Store failures during submit are logged loudly (cannot silently allow
  execution) and approval_id is cleared so the entry falls back to
  legacy resolution. Future commits will tighten this to fail-closed once
  the store becomes the gate.
- After resolution: mirror the outcome onto the store (consume on
  approve/session/always, deny on deny). Timeout leaves the row to expire
  via its expires_at column.

Tests:

tests/tools/test_approval_store_wiring.py:
- test_store_receives_proposal_with_pinned_metadata: drives
  _await_gateway_decision from a worker thread, resolves from main,
  asserts the persisted proposal has the right command, risk_reason,
  session_key, policy fields.
- test_approve_choice_marks_proposal_consumed_in_store
- test_deny_choice_marks_proposal_denied_in_store
- test_legacy_flow_without_store_unchanged: when set_default_approval_store
  is None, approval_data MUST NOT carry approval_id (wire format stays
  backward-compatible).

Regression: tests/gateway/test_approve_deny_commands.py + tests/tools/test_approval.py
both still all pass (224 + 4 new = 228 passing in targeted suite).

Phase 2/3 wiring step 1 of N.
Adds resolve_gateway_approval_by_id() — the per-id security gate that
satisfies the spec's 'no FIFO approval' invariant. Order is critical:

  1. Look up proposal in durable store. If missing / wrong session /
     non-pending → return 0 with NO side effects (fail closed silently).
  2. Atomically transition via store.consume() or store.deny(). If the
     transition returns None/False (race lost, already terminal),
     return 0.
  3. ONLY after the store transition succeeds, locate the matching
     in-memory _ApprovalEntry by approval_id and signal its event.

The blocked agent thread wakes only after the store has irrevocably
recorded the transition. The threading.Event is now strictly a wake
mechanism, never a safety boundary — exactly what the spec required:

> 'In-memory queues may remain only as notification/waiting helpers,
>  never as durable approval state.'

Cross-session attack vector closed: a user in session B cannot consume
an approval owned by session A by guessing the id — the proposal's
persisted session_key is checked against the requester before any
store mutation.

Orphan handling: when the durable proposal exists but no live waiter
remains (gateway restart, agent run finished), store.consume still
commits and we return 1. No command executes (the waiter is gone) but
the store row is correctly terminal. User feedback is consistent.

gateway/run.py changes:

- _handle_approve_command parses /approve <id> [choice]. Any
  non-keyword arg (not in {all, session, ses, always, permanent,
  permanently, once}) is treated as the approval_id and routed to
  resolve_gateway_approval_by_id. No-arg /approve still uses the
  legacy FIFO path (kept for backward compat in this commit; can
  be hardened in a follow-up).
- _handle_deny_command mirrors the same parsing for /deny <id>.

Tests:

tests/tools/test_resolve_by_id.py — 9 tests:
- correct id → consumes + signals waiter, store row=consumed
- wrong id → no signal, real proposal stays pending
- already-consumed id → returns 0, no signal
- already-denied id → returns 0, no signal
- expired id → returns 0, no signal
- cross-session attempt → returns 0, no signal, proposal untouched
- /deny <id> path → denies + signals with choice=deny
- orphan path (no waiter) → consume succeeds, returns 1
- no-store config → falls back to FIFO

Regression check (targeted):
- tests/tools/test_resolve_by_id.py: 9 passed
- tests/tools/test_approval_store_wiring.py: 4 passed
- tests/gateway/test_approve_deny_commands.py: 21 passed (legacy
  FIFO path unchanged)
- All 6 'suspect' failures observed in the broad sweep pass when
  run targeted — they are test-order/isolation effects (shared
  module-level state), not Commit B regressions.

Broad-sweep noise (NOT from this commit):
- matrix/telegram tests fail without mautrix/python-telegram-bot
  installed (those extras aren't in [dev])
- a handful of test_approval/test_command_guards failures pass when
  run alone (pre-existing test-isolation issues)

Phase 2/3 wiring step 2 of N.
Per security spec: ''/approve <id>'' is the ONLY accepted form. No FIFO.
No 'oldest pending item'. No bulk /approve all. Even with exactly ONE
pending approval, no-id MUST refuse — users must read the id from the
specific approval request rather than blindly approving whatever
happens to be at the head of a queue.

Rationale (from review):
- wrong pending command can be approved under FIFO
- user can approve without reading/copying the id
- multi-pending scenarios become footguns
- legacy convenience tends to become permanent

Changes:

gateway/run.py:
- _handle_approve_command requires non-keyword arg as approval_id.
  Returns gateway.approve.id_required if missing. Removed /approve all
  bulk path entirely. Choice keywords (session/always/once) still
  accepted but must follow the id.
- _handle_deny_command: same. /deny <id> mandatory; /deny all removed.

locales/en.yaml:
- new key gateway.approve.id_required (explanatory help text)
- new key gateway.deny.id_required
- existing 'no_pending' messages reworded to mention already
Adds the runtime-vs-pinned reclassification check at the
command-execution thread wake-up point, immediately after store.consume
succeeded but before the agent thread returns approve=True.

Per spec invariant 3 (pin policy at proposal creation):

  Allowed:    pinned high + runtime low/med   = still high (no downgrade)
  Allowed:    pinned med  + runtime med       = proceed
  FAIL CLOSED: pinned low + runtime high      = override to deny
  FAIL CLOSED: pinned med + runtime high      = override to deny
  FAIL CLOSED: missing pinned                 = override to deny
  FAIL CLOSED: classifier raises              = override to deny
  FAIL CLOSED: pinned proposal vanished       = override to deny

The consumed approval is intentionally NOT returned to pending — the
store row stays in its terminal status. User must obtain a NEW approval
with fresh pinned policy if they want to retry. This keeps exactly-once.

Implementation in tools/approval.py:

- _RISK_ORDER = {low: 0, medium: 1, high: 2} with unknown ranked above
  high so unrecognised values trigger fail-closed.
- _risk_rank(value) returns the ordinal (999 for unknown/missing).
- _classify_runtime_risk(command) returns low/medium/high using the
  same hardline + dangerous-pattern classifier as proposal creation.
- Guard sits between _drop_entry() and the store mirror in
  _await_gateway_decision. Runs only when:
    * the event resolved with an approve-tier choice
    * an approval_id was assigned (= proposal IS in the store)
    * the store is configured
- Classifier exception caught explicitly → choice = 'deny'.

Tests (tests/tools/test_phase3_runtime_guard.py):

- test_pinned_high_runtime_low_does_not_downgrade
- test_pinned_low_runtime_high_fails_closed
- test_pinned_medium_runtime_high_fails_closed
- test_matching_risk_levels_proceed_normally
- test_missing_pinned_risk_fails_closed (documents the unknown=999
  ordering: missing pinned ranks above all, so runtime cannot be
  stricter via this guard — additional pin-completeness check is
  Phase 4's job)
- test_phase3_no_downgrade_on_runtime_failure (classifier raise →
  guard catches → choice=deny, no thread leak)

Targeted suite still green: 263 passed, 3 xfailed across all
approval-related tests.

Phase 3 complete.
Replaces the uniform risk_level='medium' default with real
classifier-driven mapping. Adds a HIGH RISK prompt rendering that
includes all spec-required markers so users cannot approve blindly.

Risk mapping (_classify_pattern_risk):
- High: recursive delete (any path), SQL DROP/DELETE-without-WHERE/
  TRUNCATE, format filesystem, write/dd to block device, overwrite
  system config/file, pipe-remote-to-shell, kill-all-processes,
  recursive world-writable, recursive chown to root.
- Medium: world-writable single, stop/restart system service, force
  kill processes, disk copy (dd without of=raw), shell -c / -e script
  execution.
- Missing/unknown description → high (defensive: an unrecognised
  dangerous pattern is treated as high until proven safe).

Prompt rendering (_render_approval_display_text):

For high-risk proposals the prompt MUST and now does include:
- '🚨 HIGH RISK' marker (first line)
- 'Default: DENY' statement
- exact command verbatim
- cwd + backend context (when available)
- pinned risk reason
- approval id
- diff/summary inline if available; OR explicit
  'NOT AVAILABLE — approve only if you have independently reviewed'
  warning otherwise (spec: missing diff MUST be explicit, never silent)
- distinct two-line /approve <id> and /deny <id> footer

Medium-risk proposals get a compact form without the HIGH RISK marker
but still include command + id + reason + diff-or-warn.

ApprovalProposal stores display_text alongside structured fields so
platforms can either render bespoke UI or use the canonical text.

Tests (tests/tools/test_phase4_high_risk_ux.py): 28 passing.
- Parametrised classify_pattern_risk over 22 known descriptions
- Empty/None description → high (defensive default)
- High-risk prompt includes all 7 spec-required markers
- Missing-diff warning is verbatim, not silent
- Medium-risk omits HIGH RISK marker (UX gradient)
- End-to-end: high-risk command via _await_gateway_decision produces
  proposal + approval_data with correct risk_level/display_text
- End-to-end: medium-risk does NOT include HIGH RISK marker

Phase 4 complete.
Adds explicit fail-closed tests for the remaining gaps the security
spec calls out (invariants 6 + 7):

Store-unavailable scenarios (RaisingStore subclass simulates transient
backend failure — DB locked, disk full, permission revoked mid-session):

- store.consume raises  → ApprovalStoreError propagates, waiter NOT
  signalled, no execution
- store.get raises      → same; transition never attempted
- store.deny raises     → same; entry not denied

Per ApprovalStoreError docstring, callers MUST treat the raised error
as 'no execution', which is exactly the surface this test pins.

Submit-failure scenario:

- store.submit raises mid-_await_gateway_decision: documents current
  behavior (proposal NOT persisted, approval_id cleared on the entry,
  legacy in-memory path takes over). Crucially the entry's
  approval_id is None so any subsequent /approve <id> attempt cannot
  match a non-existent store row.

Payload integrity:

- consume with minimal payload (defaults) succeeds at the store layer
  but executor receives risk_level='low' + empty risk_reason → test
  pins this baseline so a future tightening (e.g. ApprovalStore.submit
  rejecting empty risk_reason) breaks this test and forces a review.

End-to-end-ish (real store + _await_gateway_decision + handler-style
resolve_gateway_approval_by_id + counted wake-ups):

- test_end_to_end_via_handler_exactly_one_execution:
    propose → notify carries id + HIGH RISK display_text → first
    /approve <id> wakes waiter once + consumes store row → second
    /approve <id> returns 0, no second wake. Exactly-one execution
    invariant verified.
- test_end_to_end_deny_prevents_subsequent_approve:
    /deny <id> → status=denied → subsequent /approve <id> returns 0,
    status NOT overwritten.

Phase 6/7 explicit-fail-closed coverage complete.
Adds docs/security/gateway-approval-lifecycle.md as the canonical
developer reference for the SQLite-backed approval flow. Explicitly
calls out:

- TL;DR ASCII diagram of the full lifecycle (classify → submit →
  notify → /approve <id> → consume → Phase 3 guard → execute)
- 7 non-negotiable security invariants with the exact test files
  that pin each
- Why SQLite (table comparing in-memory dict vs SQLite per invariant)
- Per-component pointer (what lives where)
- Crucial note on _ApprovalEntry.event role: notification-only,
  NEVER source-of-truth — with a 'stop and re-read' warning to
  anyone tempted to bypass store.consume
- How to add a new approval-gated action
- Per-area regression matrix (what to re-run for each change)
- Targeted + broader pytest invocations
- Out-of-scope items (request-more-after-failclosed, per-platform
  rich rendering, tighter payload validation) — each labelled
  intentional, with rationale

The doc is positioned in docs/security/ alongside the existing
network-egress-isolation note. It opens with a 'do not simplify back
to in-memory' warning so future contributors hit it before reaching
for the obvious-but-wrong refactor.

Phase 8 complete.
…ation

The test_approval_store_wiring tests were written BEFORE Phase 3+4
existed; they passed a description ('rm with recursive flag') that
mapped to medium-risk via _classify_pattern_risk but used a command
('rm -rf /important') that runtime-classified as high-risk via
detect_dangerous_command. Phase 3 guard correctly fail-closed that
mismatch as soon as it was wired in.

Real production flows never have this mismatch because pinned and
runtime use the same classifier on the same command — there is just
no path that gives them different inputs. The wiring tests had an
artificial mismatch from their pre-Phase-3 origin.

Fix: use a medium-risk command + medium-risk description that genuinely
match. The store-receives-proposal and approve-marks-consumed contracts
now exercise the realistic same-classifier flow.

Other broad-sweep 'failures' (test_approval::TestApprovalTimeoutIsNotConsent,
test_command_guards) verified to pass individually — they are pre-existing
test-order/state-isolation issues unrelated to this PR, present on baseline.
Defense in depth + tightening per review (Hermes hardening 3):

ApprovalProposal.__post_init__ now refuses construction when any
required pinned-policy field is missing or malformed:

  - approval_id: non-empty
  - created_at: > 0
  - session_key: non-empty
  - command: non-empty
  - risk_level: in {low, medium, high}
  - risk_reason: non-empty
  - policy_decision: in {allow, needs_approval, deny}
  - default_decision: in {allow, deny}
  - high-risk MUST have default_decision='deny' (non-negotiable)

All errors are collected before raising so a single ValueError surfaces
the full set of problems with the malformed payload, not a misleading
first-error-only message.

Rationale: previously, lenient construction allowed proposals with
empty risk_reason and default risk_level='low' to be persisted. The
execution-time Phase 3 guard caught these via the unknown-rank-999
fallback, but the spec calls for primary boundary at proposal-creation.
Failing early surfaces malformed callers at the call-site instead of
pushing failure into the consume/execute path.

Test updates:

- test_approval_store_sqlite.py: 2 minimal-payload tests now provide
  the required fields explicitly. Tests intent unchanged.
- test_resolve_by_id.py: expired-proposal test fleshed out with
  required risk_level/risk_reason.
- test_phase3_runtime_guard.py::test_missing_pinned_risk_fails_closed
  → renamed test_missing_pinned_risk_rejected_at_submit_time.
  Now asserts construction-time refusal. Defense-in-depth comment
  notes the Phase 3 guard still catches unknown-rank if validation
  is bypassed.
- test_failclosed_paths.py:
  - Replaces test_consume_with_missing_required_fields_in_payload_does_not_execute
    (which pinned the old lenient behavior) with two tests:
    test_proposal_missing_pinned_fields_rejected_at_construction
    (asserts ValueError on every individually-omitted field plus
    risk_level='extreme' and high-risk-with-allow-default), and
    test_consume_with_missing_required_fields_in_payload_cannot_be_submitted
    (documents that an invalid proposal never reaches consume).

Result: 96 passed, 3 xfailed in the affected suite. No regressions.

Hardening 3 of 3 review blockers.
Removes the legacy-fallback path on store.submit failure. Per spec
invariant 7, ambiguous state must fail closed; persisting an approval
without durability is exactly the trust gap this rewrite eliminated,
and falling back to the in-memory FIFO would silently reintroduce it.

Before this commit, store.submit raising would log ERROR and clear
approval_id, then continue with an in-memory _ApprovalEntry — which
the legacy resolve_gateway_approval(session_key, choice) FIFO path
could still resolve. That meant operator-misconfiguration of the
approval store could quietly downgrade safety without surfacing.

After:

- store.submit failure (any exception) immediately returns
  {'resolved': False, 'choice': None, 'store_failed': True} from
  _await_gateway_decision.
- No _ApprovalEntry is queued — there is no waiter for /approve
  to address.
- notify_cb is never invoked — the user never sees a phantom
  approval prompt for a proposal that doesn't exist durably.
- post_approval_response hook fires with choice='store_failed' so
  plugins can observe the failure mode.

Upstream callers (check_all_command_guards + check_execute_code_guard)
now route store_failed → BLOCKED with explicit operator-action message:

  'BLOCKED: Approval store unavailable — approval could not be durably
   persisted. Do NOT retry this command; the operator must investigate
   the gateway approval state database.'

Tests updated:

- test_submit_failure_falls_through_to_legacy_flow_with_no_id (pinned
  the old lenient behavior) → REPLACED with
  test_submit_failure_fails_closed_without_legacy_fallback which
  asserts no queue entry, no notify call, store_failed=True.
- New test_submit_failure_propagates_blocked_via_check_all_command_guards
  exercises the realistic end-to-end shape: dangerous command +
  failing store → BLOCKED with 'store' in the surfaced message.

Blocker 2 of 3 review blockers.
…init)

Per spec invariant 1 (pinned wrapper-side policy MUST be the active
trust boundary), GatewayRunner.__init__ now installs
SqliteApprovalStore as the default approval store via
tools.approval.set_default_approval_store().

Without this call, get_default_approval_store() returns None and
_await_gateway_decision skips the persistence layer entirely — the
legacy in-memory FIFO would still resolve /approve via
resolve_gateway_approval (lower-level). That is exactly the trust
gap the rest of this PR series replaced. Until this commit landed
the wiring was dormant in production.

Implementation:

gateway/run.py:1990 (right after self._pending_approvals = {}):
- imports tools.approval.set_default_approval_store and
  tools.approval_store_sqlite.SqliteApprovalStore
- calls set_default_approval_store(SqliteApprovalStore())
- SqliteApprovalStore() with no args resolves db_path to
  hermes_state.DEFAULT_DB_PATH which is get_hermes_home() / 'state.db'
  — respects the hermes-home override convention, no hardcoded
  ~/.hermes literal in the primary path
- exception during wiring is LOGGED ERROR loudly and store is left
  at None; operators must treat that log line as a deployment-blocker.
  Quiet downgrade was deliberately avoided.

Tests (tests/tools/test_production_store_wiring.py): 5 passing.

- test_sqlite_store_default_path_uses_hermes_state_default_db_path:
  SqliteApprovalStore() resolves to hermes_state.DEFAULT_DB_PATH
  symbol (the get_hermes_home()-derived path), not anything else.
- test_no_hardcoded_home_hermes_path_in_store_default:
  source-grep guard against future regressions that hardcode
  ~/.hermes. Allows ≤1 literal (the ImportError fallback only).
- test_gateway_init_wires_sqlite_store: exercises the exact 2-line
  wiring snippet and asserts get_default_approval_store() returns a
  SqliteApprovalStore instance, NOT None, NOT InMemoryApprovalStore.
- test_gateway_run_py_contains_sqlite_wiring_call: regex grep against
  gateway/run.py source — if a future refactor removes the
  set_default_approval_store call, this fails at CI time.
- test_in_memory_store_not_referenced_outside_tests: codebase grep
  across tools/, gateway/, agent/, hermes_cli/, hermes/. Any
  production-code instantiation of InMemoryApprovalStore would fail
  this. InMemory is test-only by contract.

Combined with the previous two commits (validate-pinned at submit,
fail-closed on submit failure), the production trust boundary is now
the SQLite store and the wiring cannot be silently lost.

Blocker 1 of 3 review blockers. All three blockers now resolved.
Targeted suite: 305 passed, 3 xfailed.
…llback)

Concern from review v2: if GatewayRunner.__init__ tries to wire
SqliteApprovalStore and the wiring raises (DB path unwritable, NFS
locked, OOM at boot), the previous behavior left _default_approval_store
at None and logged ERROR. Subsequent gateway-context approval requests
would then take the 'store is None' branch in _await_gateway_decision
and SILENTLY DEGRADE to the legacy in-memory FIFO. That is exactly the
silent-degradation pattern the rewrite was meant to eliminate.

Fix: separate 'no store by design' (tests, scripts, non-gateway flows)
from 'store init failed at boot' (production mis-wiring). Gateway boot
now marks the latter; _await_gateway_decision uses the mark to decide
between legacy-fallback (OK for non-gateway) and fail-closed (required
for production).

tools/approval.py:
- New module-level flag _approval_store_init_failed +
  _approval_store_init_failure_reason.
- Helpers: mark_approval_store_init_failed(reason),
  clear_approval_store_init_failed(), is_approval_store_init_failed().
- set_default_approval_store(non-None) clears the flag — this is the
  recovery path after a failed boot.
- _await_gateway_decision: if store is None AND init-failed flag is
  set, return {resolved: False, store_failed: True} immediately
  before any queue/notify side-effects. Operator log line names the
  recorded failure reason for diagnosis.

gateway/run.py: the except block around the SqliteApprovalStore
wiring now calls mark_approval_store_init_failed(str(exc)) AFTER
logging. Gateway continues starting (other features work) but
dangerous-command approval is hard-stopped until operator restores
wiring.

Tests (2 new in test_failclosed_paths.py):

- test_init_failure_fails_closed_no_silent_legacy_fallback: with
  init-failed flag set + store=None, _await_gateway_decision returns
  store_failed without queueing any entry or invoking notify_cb.
- test_init_failure_recovers_when_store_re_wired: installing a real
  store via set_default_approval_store clears the flag.

Targeted suite: 307 passed, 3 xfailed.

Concerns from review v2:
  NousResearch#1 silent degradation on store init failure → FIXED (this commit)
  NousResearch#2 ApprovalProposal validation in non-gateway flows → VERIFIED:
     ApprovalProposal only constructed in tools/approval.py:1497
     (gateway flow) and tools/approval_store_sqlite.py:322
     (deserialize); no non-gateway code constructs it.
  NousResearch#3 set_default_approval_store global + test leakage → VERIFIED:
     all 6 test files with approval-store usage have autouse
     fixtures restoring the global between tests.
  NousResearch#4 SQLite path uses get_hermes_home() — already verified.
  NousResearch#5 High-risk mapping scope — already balanced per test_phase4.
…ecuted

Addresses two real-bugs from review v2 self-review (Risk 1 + 2) which
should not be normalised in the very PR that hardens approval. The PR
must not ship with 'audit says approved but command never ran' as a
known/accepted behavior.

Risk 1 — Phase 3 override left audit trail misleading
-----------------------------------------------------

Before:
  user clicks /approve <id>
  → resolve_gateway_approval_by_id calls store.consume(id) → status='consumed'
  → agent thread wakes; Phase 3 guard detects runtime stricter
  → choice overridden to 'deny'
  → check_all_command_guards returns BLOCKED, command NOT executed
  → store row STILL shows status='consumed' with no audit signal that
    execution was actually blocked

Retrospective incident review six months later sees 'consumed' and
infers the command ran. It didn't.

Fix:
- New columns execution_status / execution_reason / execution_recorded_at
  on gateway_approvals table. Idempotent migration via PRAGMA-checked
  ALTER TABLE for pre-existing DBs.
- ApprovalProposal gains matching fields with defaults
  (execution_status='not_started').
- New ApprovalStore.mark_post_consume(approval_id, executed, reason)
  method on both backends. Updates only rows in status='consumed'.
- _await_gateway_decision now records the post-consume outcome:
    user_choice='deny' → status=denied, execution_status stays not_started
    user_choice approve + Phase 3 OK → execution_status='executed'
    user_choice approve + Phase 3 override → execution_status=
      'blocked_after_consume', reason='phase3_runtime_stricter'
- Distinguishes user_choice (entry.result, raw click) from final
  choice (post-Phase-3 outcome) so the audit pinpoints exactly what
  happened.
- Preserves the existing store.consume/store.deny mirror so legacy
  resolve_gateway_approval callers (platform inline-buttons in
  Telegram/Slack/Matrix/QQBot/Feishu/api_server) still get the row
  transitioned. The execution_status is the new layer on top.

Risk 2 — orphan-consumed UX falsely said 'resuming'
---------------------------------------------------

Before:
  /approve <id> with no live waiter (gateway restart, agent run done)
  → resolve_gateway_approval_by_id returned 1
  → handler returned 'gateway.approve.once_singular' =
    '✅ Command approved. The agent is resuming...'
  → user inferred command ran. It didn't.

Fix:
- resolve_gateway_approval_by_id returns -1 (new sentinel) for orphan
  case, distinct from 1 (signalled waiter) and 0 (rejected).
- Records execution_status='blocked_after_consume',
  reason='orphan_no_waiter' so audit is consistent with Risk 1 model.
- gateway/run.py _handle_approve_command + _handle_deny_command route
  count==-1 to new locale keys gateway.approve.orphan_consumed /
  gateway.deny.orphan_denied. Text states explicitly the command was
  NOT executed and instructs user to re-issue.

Tests
-----

tests/tools/test_audit_execution_status.py (5 new):
  - user_approve_then_executes_records_executed
  - user_approve_blocked_by_phase3_records_blocked_after_consume
  - user_deny_records_not_started
  - orphan_consumed_records_blocked_after_consume
  - handler_orphan_message_signals_command_not_executed (grep guard
    on gateway/run.py + locale)

test_resolve_by_id.py orphan test renamed +-extended:
  - test_resolve_by_id_orphan_consumes_store_but_returns_minus_one
    asserts return == -1 AND execution_status == 'blocked_after_consume'
    AND execution_reason == 'orphan_no_waiter'.

Schema migration
----------------

_migrate_existing_schema() runs PRAGMA table_info + ALTER TABLE ADD
COLUMN for the three new columns if absent. Existing state.db files
created by earlier hermes versions upgrade in place on first
SqliteApprovalStore() construction. No SCHEMA_VERSION bump needed
because gateway_approvals is self-contained.

Targeted suite: 312 passed, 3 xfailed. Inga regressioner.

Risk 3 (init timeout), Risk 4 (session-end cruft), Risk 5 (schema
cache caveat), and the other nice-to-haves remain in HANDOFF.md as
follow-ups per Hermes review v2 directive.

@tonydwb tonydwb 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.

Code Review Summary

Reviewed PR #41427: Harden gateway command approvals with durable atomic approval store

Decision: COMMENT — high-surface-area hardening PR (17 files, ~3.7k additions). The diff can’t be fully verified in this scheduled run.

🔴 Reviewability Note

  • This PR rewrites the approval-store contract and rewires the gateway runtime; it was not possible to review every path in cron batch mode.
  • Treat this as a review placeholder; please ensure a human review covers transactional correctness, memory-store fallout, and fail-close semantics.

Reviewed in batch on 2026-06-08

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery labels Jun 7, 2026
…TER ALTER

Real blocker from review v3 (Hermes self-test on pre-existing
state.db): the previous _ensure_schema flow was

    conn.executescript(SCHEMA_SQL)   # CREATE TABLE + CREATE INDEX
    _migrate_existing_schema(conn)   # ALTER TABLE for new columns

which fails on an upgrade with:

    sqlite3.OperationalError: no such column: execution_status

because SCHEMA_SQL contained both the CREATE TABLE IF NOT EXISTS
(a no-op on the existing table that lacks execution_*) AND the new
partial index CREATE INDEX ... ON gateway_approvals(execution_status)
WHERE execution_status = 'blocked_after_consume', which is evaluated
before the ALTER TABLE that adds the column.

Fix: split into CREATE_TABLE_SQL + INDEX_SQL and run in three steps:

    1. conn.executescript(CREATE_TABLE_SQL)   # new DBs get full shape
    2. _migrate_existing_schema(conn)         # old DBs get missing cols
    3. conn.executescript(INDEX_SQL)          # indices on guaranteed-present cols

SCHEMA_SQL kept as a back-compat alias (= CREATE_TABLE_SQL + INDEX_SQL)
for any external callers that imported the constant.

Regression test:
test_approval_store_sqlite.py::test_upgrade_from_pre_execution_columns_schema
- Builds an OLD-schema DB directly via raw SQL (matches the shape an
  earlier deploy left on disk)
- Seeds a pre-existing pending row to ensure migration doesn't drop data
- Opens via NEW SqliteApprovalStore — schema upgrades transparently
- Verifies: row still present, new execution_* fields at defaults,
  new partial index now exists, consume + mark_post_consume work
  end-to-end against the migrated DB

This is the production-upgrade path; the previous flow would have hit
this exact error on first SqliteApprovalStore() call against an
existing state.db. Catching it pre-merge prevents a deploy-time
incident.

Targeted suite: 313 passed (+1 new test), 3 xfailed.
@miclloyd
miclloyd marked this pull request as ready for review June 8, 2026 04:52
@miclloyd
miclloyd force-pushed the michael/gateway-approval-hardening branch from 3410a59 to 2c1904d Compare June 8, 2026 11:36

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the substantial hardening work. The current-main FIFO premise is real, but two blocking paths remain.

Problems

  • tools/approval.py:1777-1780 keeps the FIFO resolver for inline buttons/API. Existing adapters call resolve_gateway_approval(session_key, choice) without a durable proposal id (for example plugins/platforms/telegram/adapter.py:5402-5403), so a button can still resolve the oldest queued approval rather than the prompt clicked. This bypasses the PR's per-id guarantee on those surfaces.
  • tools/approval.py:1815 marks an approval as executed before the guard returns. Terminal dispatch occurs only after the guard (tools/terminal_tool.py:2282-2286), so a later cancellation or dispatch failure can leave a false execution audit record.

Suggested changes

  • Thread the durable approval id through send_exec_approval, all button/API callbacks, and resolve through resolve_gateway_approval_by_id for every store-backed gateway path.
  • Record executed only at the actual execution boundary, with a distinct non-executed result for post-approval dispatch failures.

Automated hermes-sweeper review.

Comment thread gateway/run.py
try:
from tools.approval import set_default_approval_store
from tools.approval_store_sqlite import SqliteApprovalStore
set_default_approval_store(SqliteApprovalStore())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wiring the store here makes it production-default, but every existing button/API handler still calls the FIFO resolve_gateway_approval(session_key, choice) without the durable id. Thread approval_data["approval_id"] through send_exec_approval and every callback before enabling this store, otherwise button clicks remain able to release the oldest queued approval.

Comment thread tools/approval.py
else:
# Approve confirmed by Phase 3 — agent thread about
# to release for execution.
store.mark_post_consume(approval_id, executed=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This records executed before _await_gateway_decision() returns to the terminal/code-execution caller; the command has not executed yet. Move this audit transition to the actual dispatch/completion boundary and record a non-executed outcome if execution cannot begin.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@egilewski

Copy link
Copy Markdown
Contributor

too large to review safely

This PR changes 1657 production lines before tests and docs. Please split it or add a focused justification if it should stay together.

Signed: GPT-5.6-luna-high in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants