Skip to content

feat: freedom co-signer policy gate (stage 1, shadow mode) - #583

Closed
gomesalexandre wants to merge 5 commits into
mainfrom
feat/freedom-cosigner-policy-gate
Closed

feat: freedom co-signer policy gate (stage 1, shadow mode)#583
gomesalexandre wants to merge 5 commits into
mainfrom
feat/freedom-cosigner-policy-gate

Conversation

@gomesalexandre

@gomesalexandre gomesalexandre commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Depends on vultisig/recipes#596.

DEPLOY ORDERING (fund-safety critical): vultisig/recipes#596 (feat/freedom-deny-support, commit 60491d1) MUST be merged and bumped in go.mod BEFORE or simultaneously with this PR. That commit adds assertArgsNative in engine/evm/evm.go which rejects native-transfer rules when the tx has calldata. Without it, a tx with value=0.01ETH + data=erc20.transfer(attacker,$1M) would pass the allow-eth-transfer rule — a P0 drain in enforce mode. The current go.mod pin (v0.0.0-20260630135030-60491d12e1c5) already points to that commit; do NOT downgrade it.

what

Adds POST /freedom/sign — a new signing surface for the agent's code-as-action feature. Users authenticate via JWT (same VaultAuthMiddleware as /auth/me) and submit EVM tx proposals. The verifier:

  1. Rejects chains not in supportedFreedomChains() with 400 (fail-closed, shadow + enforce)
  2. Extracts and derives hashes from the raw tx bytes (same as plugin signing — prevents the "bytes X, hash Y" substitution attack)
  3. Evaluates FreedomPolicy against the tx bytes
  4. In shadow mode (default): logs policy misses, signs anyway — safe ramp
  5. In enforce mode (freedom.enforce=true): 403 on policy miss
  6. Checks vault existence before enqueuing (mirrors validateAndSign)
  7. Tracks the tx via txIndexerService, enqueues TypeKeySignDKLS

FreedomPolicy caps (Ethereum mainnet)

Rule Type Details
deny-usdc-approve-unbounded DENY approve(*, uint256.max) on USDC blocked
allow-eth-transfer ALLOW native ETH MAX 0.01 ETH, any recipient
allow-usdc-transfer ALLOW USDC ERC-20 transfer MAX $10 (1e7 units), any recipient
allow-usdc-approve ALLOW USDC ERC-20 approve MAX $10 (1e7 units), any spender — bounded to small-value envelope; approve+supply bundle uses exact-amount approves ≤ the deposit

Gate tests (internal/api/freedom_test.go) — all 14 pass

Existing (5 cases):

  1. Over-cap native transfer (0.1 ETH > 0.01 cap) → denied
  2. Unbounded USDC approve (uint256.max) → denied by DENY rule
  3. Garbage tx bytes → rejected (decode error)
  4. Non-allowlisted token transfer → denied (no recipe match)
  5. Valid $1 USDC transfer → passes, returns allow-usdc-transfer rule

New (9 cases in 2 functions):

  • TestFreedomChainAllowlist: Solana, Bitcoin, BSC not in allowlist + engine errors on them (no matching rules)
  • P1-2 regression: uint256.max-1 USDC approve → DENIED (would have passed before the cap fix)
  • P1-2 positive: $5 USDC approve → ALLOWED (deposit bundles stay working)
  • P1-3 calldata guard: ETH value + ERC-20 calldata → NOT allowed by native-transfer rule (locks in assertArgsNative from recipes commit 60491d1)

Also

  • config.FreedomConfig{Enforce bool} added to VerifierConfig
  • bytedance/sonic v1.14.2→v1.15.2 (pre-existing Go 1.26 build breakage; sonic v1.14.2 has an undefined GoMapIterator symbol that blocked all test runs)

known limitations before enforcement

These are design decisions, not bugs — both must be addressed before flipping freedom.enforce=true in production:

  • No per-vault daily spend cap. The current policy allows up to 0.01 ETH per tx, any number of txs. At the session rate limit (if enforced), the maximum daily drain is bounded by sessions × max-per-tx. A per-vault daily accumulator (e.g. Redis INCRBY, 24h TTL) should be added as a pre-enforcement TODO.
  • TARGET_TYPE_UNSPECIFIED on native ETH = any recipient. The allow-eth-transfer rule caps amount but not destination. A compromised agent can send 0.01 ETH to any address repeatedly. This is intentional for the current UX (no whitelist), but means the blast radius for native ETH is unbounded in total (amount-per-tx is bounded). A recipient allowlist or daily accumulator mitigates this.

open questions / stage 2 todos

  • Rate limiting (86400s window, 10 tx max) — defined in design but not implemented in stage 1; the policy eval is the critical gate for now
  • Freedom vault share provisioning: the DKLS worker looks up GetVaultBackupFilename(pubKey, "vultisig.freedom"); users need a freedom vault share installed before signing works end-to-end
  • Multi-chain: policy currently covers Ethereum mainnet only; other EVM chains + Solana follow-up
  • Native launch smoke test: CI builds the binary but never does a cold launch — the one check that caught the RN crash pattern

risk

Low in shadow mode (default). Do NOT flip freedom.enforce=true without first merging vultisig/recipes#596 and the two pre-enforcement TODOs above.

🤖 Agent-generated

Adds POST /freedom/sign — a new signing surface authenticated via user
JWT (VaultAuthMiddleware, same as /auth/me) that gates outbound txs
through a static EVM policy before handing off to the existing DKLS
keysign pipeline.

New files:
- internal/api/freedom_policy.go  — FreedomPolicy (DENY + ALLOW rules)
- internal/api/freedom.go         — FreedomSign handler
- internal/api/freedom_test.go    — 5 gate tests (all pass, no infra needed)

Route: POST /freedom/sign (VaultAuthMiddleware)

FreedomPolicy caps (Ethereum mainnet):
- DENY: USDC approve(*, uint256.max) — blocks unbounded approval drains
- ALLOW: native ETH transfer MAX 0.01 ETH, any recipient
- ALLOW: USDC ERC-20 transfer MAX $10 (1e7 units), any recipient
- ALLOW: USDC ERC-20 approve MAX uint256.max-1, any spender

Shadow mode (cfg.Freedom.Enforce=false, default):
policy misses are logged but signing proceeds — safe zero-downtime ramp.

Enforcement mode (cfg.Freedom.Enforce=true, opt-in / used in tests):
policy miss → 403 Forbidden.

Hash derivation mirrors validateAndSign: verifier derives hashes from
txBytes independently, ignoring any client-provided hash values.

Depends on vultisig/recipes feat/freedom-deny-support for:
- DENY-wins semantics in engine.Evaluate
- TARGET_TYPE_UNSPECIFIED ("any recipient") in EVM assertTarget
- Calldata guard in assertArgsNative (prevents contract-call txs from
  matching the native-transfer amount cap)

Also bumps bytedance/sonic v1.14.2→v1.15.2 (pre-existing Go 1.26 build
breakage — sonic v1.14.2 has an undefined GoMapIterator symbol on Go
1.26 that blocked all test runs in this repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e40876af-63db-40b3-af8d-240f53d85a7f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/freedom-cosigner-policy-gate

Comment @coderabbitai help to get the list of available commands.

gomesalexandre and others added 4 commits June 30, 2026 16:48
…orce

supportedFreedomChains() was declared but never called. Without the guard,
any non-Ethereum chain (Solana, BTC, THORChain, non-ETH EVM) reached policy
evaluation with zero matching rules. In shadow mode "no matching rule" is a
pass → those chains would be co-signed with no policy check at all.

Reject unsupported chains with 400 immediately after firstMsg is bound, before
the engine is even created. Fail-closed in both modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
… drain

allow-usdc-approve had MaxValue = uint256.max-1. A compromised agent could
issue approve(drainer, ~∞) on the user's USDC and then transferFrom the full
balance — bypassing the co-signer entirely. The DENY rule only caught the
exact uint256.max boundary, not uint256.max-1.

Cap the approve to 10_000_000 (i.e. \$10 USDC, 6 decimals) — same envelope
as the transfer rule. The aave deposit bundle uses exact-amount approves so
\$10 covers every legitimate freedom flow while bounding blast radius to \$10.

The DENY-uint256.max rule is kept as belt-and-suspenders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
Without this check, a valid JWT for a vault that has not yet been onboarded
would successfully enqueue a keysign task. The worker would then fail to
execute it (vault file not found), wasting a signing slot and creating a
polluted tx indexer entry. Mirror the plugin.go validateAndSign existence
gate: 404 if the freedom vault backup file is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
…guard tests

Three new test surfaces:

TestFreedomChainAllowlist — verifies supportedFreedomChains() excludes Solana,
Bitcoin, BSC, and that the engine also errors on those chains (no matching
rules). Ensures the P1-1 HTTP handler guard has a matching engine-level check.

P1-2 regression: near-infinite approve (uint256.max-1) must be DENIED after
tightening allow-usdc-approve to \$10. Would have passed before the fix.
Also: \$5 USDC approve must still pass (positive path for deposit bundles).

P1-3 calldata guard lock-in: ETH value + ERC-20 calldata must NOT pass the
native-transfer rule. Guards vultisig-recipes assertArgsNative (commit 60491d1).
If that guard regresses, this test fails and blocks the deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
@gomesalexandre

Copy link
Copy Markdown
Contributor Author

Adversarial fund-safety review → fixed

Ran a dedicated adversarial review of this gate (the signing-path control). It confirmed the core is sound — hash-from-bytes derivation overrides the client hash unconditionally (the "bytes X, signed hash Y" attack is closed), DENY-wins is correct, JWT vault-key binding is correct, enforce mode is fail-closed everywhere. It found 3 P1 blockers + P2s, all now fixed + tested:

  • P1-1 (d1bdeaa): supportedFreedomChains() was dead code → in shadow, a Solana/BTC/THORChain/non-ETH-EVM tx would be signed with zero policy eval (all rules are ethereum.*). Now the allowlist is enforced at the top of FreedomSign → 400, fail-closed in shadow AND enforce.
  • P1-2 (4175861): allow-usdc-approve capped uint256.max-1 (≈infinite) to ANY spender → a compromised agent could approve(drainer, ~∞) then transferFrom the whole balance, fully bypassing the co-signer. Capped to $10 (10000000) — same ceiling as the transfer rule; the DENY-uint256.max rule kept as belt-and-suspenders. Regression test: uint256.max-1 approve now DENIED (passed pre-fix), $5 approve allowed.
  • P1-3 (63f245b): the calldata guard (assertArgsNative rejects value+data) lives in recipes — verifier-before-recipes would be a drain ({to:USDC, value:0.01ETH, data:transfer($1M)} slipping allow-eth-transfer). The go.mod pin (60491d1) includes the guard; added a lock-in test that fails if the pin is ever downgraded, + a deploy-ordering callout at the top of this PR.
  • P2-1 (8bbabd3): vault-existence check before enqueue (mirrors validateAndSign).
  • P2-2: gate suite grown 5 → 14 subtests (chain allowlist, P1-2 regression, P1-3 guard) — all green (go test ./internal/api/ -run Freedom ✓).

P2-3 (per-vault daily spend cap) + P2-4 (any-recipient) are documented as pre-enforcement design TODOs in the "known limitations" section. With these, the gate is Stage-1 (shadow) sound and ready to progress toward capped enforcement once the daily-cap decision is made. (Codex second-pass still worth running before flipping enforce.)

@gomesalexandre

Copy link
Copy Markdown
Contributor Author

Closing: the current stack signs freedom txs on-device via the existing 2-of-2 (validateAndSign / plugin-signer), not through a server-side co-signer.

  • POST /freedom/sign has ZERO callers (abts, app, and mcp-ts all grep 0) and would need a separate vultisig.freedom vault-share provisioning flow that does not exist.
  • The already-live freedom feature (freedom_protocol_action / freedom_cosmwasm_execute) renders execute_contract_call-shaped cards that sign on-device by the user's existing 2-of-2 - the same path every other tx uses. In the live stack verifier is only the optional plugin-skills fetcher (VERIFIER_URL/plugins/available), not a co-signer.
  • Fund-safety for the live freedom path is enforced by the abts Mastra floor (discovery grounding + calldata correctness + NS-3 warnings, agent-backend-ts Resolve type drift between recipes Schedule and tx indexer schedule #193) plus the on-device user confirm.

Superseded by that model. Reopenable if we later decide we want a server-side policy co-signer (defense-in-depth, Stage-4). recipes #596 (deny-wins/calldata-guard) left open pending a separate call on its standalone value to the recipes engine.

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