Skip to content

fix(demo): fail-closed gate for submit_safe_tx_signature under demo mode (#775) - #828

Merged
szhygulin merged 1 commit into
mainfrom
fix/775-demo-gate-submit-safe-tx
Jul 26, 2026
Merged

fix(demo): fail-closed gate for submit_safe_tx_signature under demo mode (#775)#828
szhygulin merged 1 commit into
mainfrom
fix/775-demo-gate-submit-safe-tx

Conversation

@graciangabriel8

Copy link
Copy Markdown
Contributor

Closes #775

What was wrong

submit_safe_tx_signature is registered with a plain handler(...) (src/index.ts:2215-2229). In makeDemoDispatch (src/index.ts:1138+) it matches none of the three intercepted shapes — it is not sign_/pair_ledger_/prepare_-prefixed, and it is absent from both ALWAYS_GATED_EXPLICIT and CONDITIONALLY_GATED_EXPLICIT — so under isDemoMode() the dispatcher falls through to return realHandler(args).

The real handler (src/modules/safe/actions.ts:287+) then calls kit.proposeTransaction(...) / kit.confirmTransaction(...) — a live HTTP write to the Safe Transaction Service. A pending Safe multisig tx gains a signature, visible to every co-signer and one step closer to execution, while the user believes demo mode means nothing real happens.

Reachability (issue step 1)

The issue asked whether a demo user can reach this at all post-#772. Yes. The tool consumes no device signature — it derives an approved-hash sender signature from the signer address (encodeApprovedHashSignature). Its only precondition is the on-chain read approvedHashes(signer, safeTxHash) != 0, which is satisfied by an approval mined before demo was entered, or produced from the Safe Web UI / by a co-signer entirely outside this server. #772's device-signing gates therefore do not contain it.

What changed

  1. src/demo/index.ts — added "submit_safe_tx_signature" to ALWAYS_GATED_EXPLICIT with inline rationale. Always-gated, not conditionally: every conditionally-gated tool except the broadcast tool runs the real handler in live demo mode, and broadcastSimulationDispatch is bound to the send_transaction handle/simulate flow and cannot wrap an STS POST. The tool has no read-only branch — every successful call writes — so gating loses no inspection-only demo UX. Same always-gate shape as finalize_btc_psbt / prepare_btc_multisig_send, different sink class.

  2. test/support/sink-reachability.ts — extended PROP_SINKS with proposeTransaction / confirmTransaction (the issue's step 2: an off-chain-state-write sink category), plus a header-doc entry. Their only call sites in src/ are inside submitSafeTxSignature, so this widens the analysis by exactly the one tool security(demo): submit_safe_tx_signature posts to the off-chain Safe Transaction Service under demo mode (off-chain-write sink class, follow-up to #772) #775 is about — verified by grep across src/ (the other matches are interface declarations in sdk.ts and { op: "proposeTransaction" } diagnostic string literals, neither of which is a call site).

Tests

Falsifier — test/775-safe-tx-service-demo-gate.test.ts (new). Drives the exact production dispatch decision via the extracted makeDemoDispatch factory with a spy realHandler, mirroring test/demo-btc-containment.test.ts exactly.

  • RED before the fix: the tool matched neither gate list, so dispatch fell through and the spy was called — the "never invokes the real handler" expectations fail.
  • GREEN after: always-gated, spy never called, structured VAULTPILOT_DEMO refusal returned.
  • Non-vacuity: a positive control with demo OFF asserts the same dispatch does reach the spy, so the GREEN "not called" is real containment, not a dead wire. Also asserts the gate does not over-reach into get_safe_positions / prepare_safe_tx_execute.

test/demo-sink-gating.structural.test.ts — new case pinning both halves together: submit_safe_tx_signature is detected as sink-reaching (RED if the sink names are dropped from PROP_SINKS) and contained (RED if the gate is reverted). The pre-existing "every sink-reaching tool is contained" invariant now guards it mechanically.

test/support/sink-reachability.sts-matcher.test.ts (new) — positive liveness for the two new sink names against a synthetic parsed AST, mirroring the #778 wc-matcher precedent. Needed because bodyHasSink returns on the first sink found and both names are called inside the same function, so real code only ever exercises proposeTransaction; confirmTransaction would otherwise be an unverified matcher. Includes two non-vacuity negative controls (a different method name; the sink names used as plain string literals, which is the real { op: "proposeTransaction" } shape in actions.ts).

Tests were NOT run locally — Node/npm are not installed on the authoring machine, so nothing was executed and no local result is claimed. CI (Build & Test on Node 20 + 22) is the authoritative check.

Existing tests

None weakened, none updated. test/safe-propose.test.ts calls submitSafeTxSignature directly as a module function rather than through the dispatcher, so demo gating does not affect it. test/support/dump-sink-analysis.test.ts asserts sinks.length >= 4; the sink count only grows. test/support/sink-reachability.wc-matcher.test.ts got a header-comment note only (its "every other sink is exercised end-to-end today" claim would otherwise be stale) — no assertion touched.

Blast radius

Small and demo-only. The gate fires only under isDemoMode(); with demo off the dispatch is an unchanged pass-through. The one user-visible behavior change: a demo user calling submit_safe_tx_signature now gets the standard structured always-gated refusal instead of a live STS POST. The PROP_SINKS change is test-support-only and does not ship in src/.

Residual concerns

…ode (#775)

submit_safe_tx_signature matched none of the demo dispatcher's three
intercepted shapes, so makeDemoDispatch fell through to the real handler
and POSTed to the live Safe Transaction Service (kit.proposeTransaction /
kit.confirmTransaction) while the user believed demo mode meant nothing
real happens. The tool's only precondition is an on-chain
approvedHashes(signer, safeTxHash) != 0 read, which a demo user with a
real pre-existing approval satisfies -- #772's device-signing gates do not
make it unreachable.

- Add "submit_safe_tx_signature" to ALWAYS_GATED_EXPLICIT (off-chain write
  with no demo-simulation equivalent; conditional gating would still run
  the real handler in live demo).
- Extend the structural walker's sink set with the off-chain-write class
  (.proposeTransaction / .confirmTransaction) so the mechanical
  containment invariant covers it, per the issue's step 2.
- Add a behavioral falsifier driving makeDemoDispatch with a spy handler,
  plus synthetic positive-liveness for both new sink names (the DFS
  short-circuits past confirmTransaction in real code) and a structural
  assertion pinning detection + containment together.

Closes #775
@szhygulin
szhygulin marked this pull request as ready for review July 26, 2026 08:06
@szhygulin
szhygulin merged commit 1e28884 into main Jul 26, 2026
3 checks passed
@szhygulin
szhygulin deleted the fix/775-demo-gate-submit-safe-tx branch July 26, 2026 08:06
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(demo): submit_safe_tx_signature posts to the off-chain Safe Transaction Service under demo mode (off-chain-write sink class, follow-up to #772)

2 participants