fix(demo): fail-closed gate for submit_safe_tx_signature under demo mode (#775) - #828
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #775
What was wrong
submit_safe_tx_signatureis registered with a plainhandler(...)(src/index.ts:2215-2229). InmakeDemoDispatch(src/index.ts:1138+) it matches none of the three intercepted shapes — it is notsign_/pair_ledger_/prepare_-prefixed, and it is absent from bothALWAYS_GATED_EXPLICITandCONDITIONALLY_GATED_EXPLICIT— so underisDemoMode()the dispatcher falls through toreturn realHandler(args).The real handler (
src/modules/safe/actions.ts:287+) then callskit.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
signeraddress (encodeApprovedHashSignature). Its only precondition is the on-chain readapprovedHashes(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
src/demo/index.ts— added"submit_safe_tx_signature"toALWAYS_GATED_EXPLICITwith inline rationale. Always-gated, not conditionally: every conditionally-gated tool except the broadcast tool runs the real handler in live demo mode, andbroadcastSimulationDispatchis bound to thesend_transactionhandle/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 asfinalize_btc_psbt/prepare_btc_multisig_send, different sink class.test/support/sink-reachability.ts— extendedPROP_SINKSwithproposeTransaction/confirmTransaction(the issue's step 2: an off-chain-state-write sink category), plus a header-doc entry. Their only call sites insrc/are insidesubmitSafeTxSignature, 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 acrosssrc/(the other matches are interface declarations insdk.tsand{ 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 extractedmakeDemoDispatchfactory with a spyrealHandler, mirroringtest/demo-btc-containment.test.tsexactly.dispatchfell through and the spy was called — the "never invokes the real handler" expectations fail.VAULTPILOT_DEMOrefusal returned.get_safe_positions/prepare_safe_tx_execute.test/demo-sink-gating.structural.test.ts— new case pinning both halves together:submit_safe_tx_signatureis detected as sink-reaching (RED if the sink names are dropped fromPROP_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 #778wc-matcherprecedent. Needed becausebodyHasSinkreturns on the first sink found and both names are called inside the same function, so real code only ever exercisesproposeTransaction;confirmTransactionwould 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 inactions.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.tscallssubmitSafeTxSignaturedirectly as a module function rather than through the dispatcher, so demo gating does not affect it.test/support/dump-sink-analysis.test.tsassertssinks.length >= 4; the sink count only grows.test/support/sink-reachability.wc-matcher.test.tsgot 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 callingsubmit_safe_tx_signaturenow gets the standard structured always-gated refusal instead of a live STS POST. ThePROP_SINKSchange is test-support-only and does not ship insrc/.Residual concerns
analyzeRegisteredTools()now conflates two sink classes (device-sign/broadcast from security(demo): sign_btc_multisig_psbt + finalize_btc_psbt(broadcast) reach a REAL Ledger signature and mainnet broadcast under demo mode #772, off-chain write from 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) behind onesinkReachingboolean. That is what makes the containment invariant cover this tool, but a future off-chain-write sink whose correct containment is not "always-gate" would need the taxonomy split tracked in arch(substrate): hand-maintained fail-open enumerations are this codebase's default guard habit — 4 instances (#757/#764/#772/#771) #776. Flagged, not solved here.docs/bug-list-and-proposed-fixes.mdstill lists 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 under open bugs. That file is a dated snapshot ("Generated 2026-07-20"), and prior merged fixes (security: prepare_swap(toAddress=ATTACKER) drains to the LiFi Diamond unstamped — #760 closed on a partial fix; intent check is tautological against a rogue agent #798/security: INTERMEDIATE_CHAIN_BRIDGES relaxes the chainId gate on a FALSE premise — 1885080386571452 is TRON's on-chain id, not NEAR's; #237 also still broken #799/SEC verdict: ARCHITECTURE.md §3.5 SafeexecTransactionsafeTxOrigin skips block 4 — UNSOUND, requires mitigation #761) left their entries in place, so it was deliberately not edited here.