feat: add Phase 1 local transaction policy engine#55
Conversation
Implements the foundational engine for local transaction policies per the
PRD. Policies are plain JS objects registered against the WDK instance;
the engine wraps every write-facing operation on a wallet account (and on
protocol getters) so DENY rules throw PolicyViolationError before the
underlying method runs and ALLOW rules pass through untouched.
- Three-group evaluation (account → wallet → project) with DENY-wins and
account-level override_broader_scope to grant explicit exceptions.
- Conditions are user-supplied functions, sync or async, with full access
to a frozen context { operation, chain, account: <readOnly>, params, args }.
- account.simulate.<method>(...) mirror runs evaluation without execution
and returns { decision, policy_id, matched_rule, reason, trace }.
- Nested-call escape via Symbol-keyed flag so approve()/bridge() that
internally call sendTransaction() are not double-evaluated.
- Registration validation throws PolicyConfigurationError synchronously on
unknown ops, missing fields, and contradictory configuration; no
soft-mode.
- 17 supported operations + wildcard (sendTransaction, transfer, approve,
signMessage, signHash, signTypedData, signAuthorization, delegate,
revokeDelegation, swap, bridge, supply, withdraw, borrow, repay, buy,
sell).
- Phase 2/3 hooks reserved: state and onSuccess fields accepted on rules
and registration options, ignored at runtime.
Public exports added to index.js: PolicyViolationError,
PolicyConfigurationError, plus the Policy / PolicyRule / PolicyCondition /
PolicyContext / PolicyAction / PolicyScope / PolicyOperation /
SimulationResult / SimulationTraceEntry / RegisterPolicyOptions typedefs,
and a re-export of IWalletAccountReadOnly so consumers can type their
condition functions without depending on the parent SDK directly.
Test coverage: 49 new tests in tests/wdk-manager-policy.test.js, all
through the public WDK API; 80 total passing across the suite.
Concurrency (blocker) - Replace per-account POLICY_CTX flag with AsyncLocalStorage. The flag approach silently bypassed evaluation for the second of any two concurrent calls on the same account: call A's in-flight marker was visible to call B during A's await, and B took the nested-escape branch. AsyncLocalStorage scopes the marker to the async chain that set it, so concurrent calls evaluate independently while nested calls within one chain still skip re-evaluation. - Add regression tests: concurrent ALLOW (both evaluate, both run) and concurrent DENY (both throw, neither executes). TOCTOU on params - buildContext now structuredClones each cloneable arg. Conditions see a snapshot taken at evaluation time; mutating the original tx object during an async condition's await no longer changes what conditions evaluated. The original args still flow through to original(...args) untouched. - Tests for both directions: caller-side mutation after the call starts doesn't affect what conditions saw, and condition-side mutation doesn't propagate to the underlying call. PolicyViolationError ergonomics - Add optional rule.reason field. When set on a DENY rule, propagates to err.reason, simulate-result.reason, and the error message. - When err.reason equals err.ruleName (the default fallback), drop the redundant trailing ": <reason>" from the message. Trace shape consistency - SimulationTraceEntry now uses snake_case (policy_id, rule_name) to match the snake_case top-level SimulationResult. Previously inconsistent (top-level snake_case, nested camelCase). Integration diagnostic - The wrapper checks for IWalletAccount.toReadOnlyAccount() and throws PolicyConfigurationError with a clear message if missing, instead of surfacing a TypeError from inside engine internals. Defensive policy clone - PolicyRegistry.add now stores a shallow clone of the policy and its rules so callers cannot mutate engine state by editing the original object after registration. Typedef docs - PolicyRule.override_broader_scope: documented as account-scope ALLOW only, registration-order matching, short-circuits both wallet and project evaluation. - PolicyRule.reason: documented. - Policy.accounts: documented as exact-string match only in Phase 1. - PolicyContext.params and .args: documented as structured clones. Test count: 87 passing (was 80), all through the public WDK API.
- README: add 'Transaction Policies' to Key Capabilities and a focused usage section showing registerPolicy(), DENY-with-PolicyViolationError, and account.simulate.<method>(). Mentions the three scopes, override_broader_scope, and the upcoming templates package. - AGENTS.md: extend Repository Specifics to include policies as a third orchestrated lifecycle alongside wallets and protocols, and add a Policy Engine section covering source layout, public surface, AsyncLocalStorage marker, Phase 2 reservations, and the public-API test discipline.
claudiovb
left a comment
There was a problem hiding this comment.
Policy Engine Review — Phase 1
Solid architecture. Three-group evaluation semantics, DENY-wins, AsyncLocalStorage concurrency isolation, TOCTOU protection, and simulate mirror are all correct. 87/87 tests pass, lint clean, types clean. A 20-scenario E2E dogfood suite against real WalletManager/WalletAccount/SwapProtocol/BridgeProtocol subclasses passes end-to-end.
Findings
| ID | Severity | Title |
|---|---|---|
| C-1 | Critical | bare-async-hooks missing AsyncLocalStorage it breaks WDK import on Bare |
| H-1 | High | No timeout on async conditions a never-resolving Promise hangs forever |
| H-2 | High | Shallow policy clone the rule.state shared by reference after registration |
| H-3 | High | Throwing DENY condition bypassed when a matching ALLOW exists |
All four are tested with reproduction gists in the inline comments.
Informational
- I-1:
structuredClonefallback to raw value for non-cloneable args (policy-context.js:52) which is the only way to go I think, worth documenting though. - I-2: No policy introspection API for listing registered policies or inspecting governed operations at runtime this would be really valuable for agentic tooling, worth considering for Phase 2.
- I-3: PR description says "80/80 passing (31 existing + 49 new)" the actual count is 87 (38 + 49).
- I-4: PR description says "Symbol-keyed flag" for nested-call escape but code uses
AsyncLocalStoragesince commit7567876.
JSDoc, style, and types review to follow.
- C-1: Lazy-load node:async_hooks in policy-account-wrapper so importing WDK on Bare doesn't fail at link time. AsyncLocalStorage is only resolved when an account is actually wrapped; if the runtime can't provide it, applyPoliciesToAccount throws a clear PolicyConfigurationError. - H-1: Race each condition against a configurable conditionTimeoutMs (default 30s). On timeout, the condition is surfaced as a throw and follows the same fail-mode as any other condition error. Configurable via RegisterPolicyOptions, validated at registration. - H-2: Deep-clone rule.state with structuredClone in the registry so Phase 2's engine-managed state can't be mutated by callers post- registration. - H-3: A throwing or timing-out DENY condition is now treated as matched (fail-closed) rather than skipped. An attacker can no longer bypass a DENY by causing its backing service to throw while a sibling ALLOW would have matched. ALLOW rules retain fail-open-as-no-match. Tests: +8 covering timeouts (allow/deny paths, validation, no-op when condition resolves quickly), throwing DENY fail-closed, throwing ALLOW falls through, rule.reason precedence, and defensive deep-clone.
…cies
Both changes per PM feedback (Lokesh).
Wallet scope removed:
- The 'wallet' scope was mechanically redundant once project-scope policies
respect their chain binding. With DENY-wins evaluation, the precedence
layer (account → wallet → project) collapsed to (account → project)
because the project layer never won over wallet anyway.
- Project-scope policies now narrow by chain when registered with a chain
argument. registerPolicy('ethereum', policy) applies to ethereum only;
registerPolicy(['ethereum','ton'], policy) applies to both;
registerPolicy(policy) applies globally.
- disposeChain now narrows project policies bound to the disposed chain
(and removes them entirely if the chain list becomes empty), so the
wallet-scope dispose semantics are preserved.
Account-scope policies accept integer indexes:
- The accounts field accepts a mixed array of derivation paths and
non-negative integer indexes — accounts: [0, "44'/60'/0'/0/0"].
- Index entries match the index passed to wdk.getAccount(chain, index).
- Index entries do not match accounts retrieved via getAccountByPath
(no synchronous path → index resolver on the wallet manager).
Path entries match either retrieval style. Documented.
Tests: 7 new (chain-bound project narrowing, mixed accounts array,
index-vs-path retrieval semantics, validator rejects non-integer /
negative entries). 101/101 pass. Smoke 51/51. Lint + types clean.
Docs (README, AGENTS.md) updated to reflect the two-scope model.
Adds src/policy/policy-context-store.js. Picks the implementation at first use: - On Node (and any runtime exposing node:async_hooks.AsyncLocalStorage): wraps the native primitive directly. Zero overhead, full nested-call escape, concurrent-call isolation as before. - On Bare (and any runtime missing AsyncLocalStorage): falls back to a no-op store. getStore() always returns undefined, run() just calls fn(). Effect: every wrapped method call evaluates policies independently, including nested calls. Why a no-op rather than a Promise-prototype-patch shim: modern V8 (since 2018) inlines `await` to skip user-visible Promise.prototype.then, so the Zone.js technique no longer captures context across awaits in pure user-space. The only correct propagation paths are AsyncLocalStorage or the upcoming TC39 AsyncContext, both of which require runtime support. Bare is waiting on AsyncContext to reach Stage 3 + V8 implementation before adding AsyncLocalStorage upstream (per Holepunch); when that lands, this fallback is automatically replaced by the native path with no code changes needed here. Behavioral consequence on Bare: nested wrapped-method calls re-evaluate (e.g. a project DENY on sendTransaction will block a bridge() that internally sends a transaction). Documented in README and AGENTS.md under "Runtime support". Every other engine behavior — basic ALLOW/DENY, async conditions, concurrent-call isolation, account.simulate, fail- closed DENY — works identically on both runtimes. Tests: 7 new in tests/wdk-manager-policy-bare.test.js, mocking node:async_hooks via jest.unstable_mockModule to force the fallback path through the public WDK API. 108/108 pass total. Lint + types clean. Smoke 51/51.
…es on Per PR feedback: the policy engine doesn't actually know about blockchains. It routes policies based on whatever string was passed to `registerWallet` — that string can be a chain name, but it could equally be `"treasury-cold"`, `"my-hot-wallet"`, or any consumer-chosen label. Calling the parameter `chain` was misleading. API rename: - `wdk.registerPolicy(chain, policy)` → `wdk.registerPolicy(wallet, policy)` - `PolicyContext.chain` → `PolicyContext.wallet` (passed to conditions) - All error messages mentioning "chain" → "wallet" Internal rename: - `PolicyRegistry._accountByChain` → `_accountByWallet` - `policy._chains` → `policy._wallets` - `disposeChain` → `disposeWallet` - `normaliseChainArg` → `normaliseWalletArg` - All param names follow Out of scope: WDK manager keeps `blockchain` for `registerWallet` / `getAccount` / `getAccountByPath` since that's existing API; the rename is local to the policy engine surface. Future-proof: if real chain-level awareness is ever needed (e.g. "deny on Polygon regardless of which manager"), it would be additive — a `getChainId()` getter on wallet packages so the engine can ask the provider what chain it's connected to. Not required for Phase 1. Tests + smoke pass: 108/108, 51/51. Lint + types clean.
…e and Bare Replaces the AsyncLocalStorage-based wrapper (and its no-op Bare fallback) with a single Proxy-based mechanism that works identically on every runtime that supports ES Proxy. How it works - `_applyPolicies` returns a Proxy wrapping the underlying account instead of mutating it. WDK manager returns this proxy from `getAccount` / `getAccountByPath`. - The proxy's `get` trap exposes enforced versions of write methods. - For every non-enforced property, the trap returns the underlying value; if it's a function, it's bound to the underlying account. - Internal SDK code that calls `this.someMethod()` resolves on the underlying account, not the proxy — bypassing enforcement naturally. - Protocol packages that hold a direct reference to the account (constructed before the proxy exists) likewise bypass enforcement. - Only access through the proxy goes through policy evaluation. Why this is better than what we had - Identical code path on Node and Bare. No `AsyncLocalStorage`, no `node:async_hooks` dependency, no runtime detection, no Promise-prototype patching, no fallback. - Eliminates the divergence we shipped previously (Node had nested-call escape, Bare didn't). Both now work the same way. - Doesn't depend on V8 internals or TC39 AsyncContext landing. - The original account stays clean — easier to reason about and matches the "policy proxy is a view, not a mutation" mental model the dev portal will need in later phases. Files - Added: `src/policy/policy-account-proxy.js` - Removed: `src/policy/policy-account-wrapper.js`, `src/policy/policy-context-store.js`, `tests/wdk-manager-policy-bare.test.js` (the Bare-divergence tests no longer apply — coverage for nested escape is in the main test file and works the same on every runtime). - Updated: `src/policy/policy-engine.js` (calls the new factory), `src/wdk-manager.js` (returns the proxy), README + AGENTS.md (drop the "Bare-specific limitation" section). Tests + types + lint + smoke all clean: 101/101 tests, 51/51 smoke.
Per Lokesh + Jonathan: a policy should be self-describing, so the wallet binding lives inside the policy object instead of as a positional arg to registerPolicy. `wallet` accepts `string | string[]`, optional for project scope (omitting = all wallets), required for account scope. The dual overload of registerPolicy is gone. Also gitignore .scripts/.agents (per wdk-local-files convention).
C-2 (critical): proxy.registerProtocol(...) was returning the raw account (per _registerProtocols closure), letting callers escape enforcement via the chained form `proxy.registerProtocol(...).sendTransaction(...)`. The proxy's get trap now intercepts registerProtocol and rewrites the return value to the proxy itself. Regression test added. H-4 (doc): added security/portability note to README + AGENTS clarifying that enforcement applies to the proxy surface only, and underscore-prefixed fields like `protocol._account` bypass by design (private by convention, matches the documented nested-call escape behavior). Doc-1: JSDoc on `override_broader_scope` still mentioned wallet scope which was removed in 7889bc3. Cosmetic: simulate.getXProtocol(label) now accepts the label arg for parity with the real account.getXProtocol(label) — silently swallowed today, reserved for Phase 2.
…ation) D-1..D-6, D-8..D-12: style/JSDoc/structure cleanups across constants, policy-engine, policy-error, policy-registry, policy-validators. D-7 + D-13: zod adoption for policy + register-options validation. Schemas live in src/policy/policy-schemas.js. policy-validators.js becomes a thin wrapper that runs the schemas and maps ZodError → PolicyConfigurationError via a formatter that preserves the existing error message contract. Sets us up for one-liner JSON-schema export for the dev portal (z.toJSONSchema(policySchema)). Verified compatible with Bare runtime: zod imports cleanly, schemas parse, refinements fire — full 11/11 Bare end-to-end suite passes. Other notable changes: - PolicyViolationError uses single-object constructor arg with private fields and documented getters (D-11, D-12). - PolicyRegistry uses Map instead of Object.create(null) (D-8). - collectReferencedOperations early-returns on wildcard hit (D-6). - Cross-field policy rules (account-scope requirements, override_broader_scope constraint) consolidated in one superRefine. Verification: lint clean, types clean, 105/105 Jest, 51/51 smoke, 11/11 Bare, 8/8 EVM integration, 6/6 ERC-4337 integration.
Drops the bespoke message formatter (~80 lines) in favor of a 20-line helper that just prefixes zod's default issue message with the policy (and rule, where applicable) context. superRefine messages are now short and field-scoped; the formatter unifies the prefix. Test assertions on validator error messages switched from exact toBe(...) to toMatch(/regex/) capturing the policy id and field — same intent, less brittle to message wording. policy-validators.js + policy-schemas.js: 326 → 252 lines, now 15 lines smaller than the pre-zod imperative version. All checks still green: 105/105 jest, 51/51 smoke, 11/11 Bare, 8/8 EVM integration, 6/6 ERC-4337 integration, lint+types clean.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Sweeps across the policy engine source + wdk-manager.registerPolicy:
R1 — class JSDoc on PolicyEngine + PolicyRegistry now starts with the
description, @internal at the end.
R2 — @typedef declarations use {Object} (capitalized) — 6 sites in
policy-engine.js.
R3 — every @param/@Property tag has a dash before the description.
R11 — replaced inline `import('zod').ZodError` with a top-of-file typedef
alias in policy-schemas.js.
R13 — bare `object` / `object[]` / `Promise<object>` types replaced with
named typedefs (Policy, PolicyRule, PolicyContext, IWalletAccount,
PolicyGroups, PolicyVerdict, Verdict, ZodError, PolicyEngine).
R28 — every documented field now carries a meaningful description; the
public typedefs (Policy, PolicyRule, SimulationResult, etc.) gained
one-line descriptions per property that consumers see in IDE hover.
Public-API surface (Policy + PolicyRule + SimulationResult typedefs,
PolicyViolationError, PolicyConfigurationError, registerPolicy) gets
the strictest treatment since these ship via the generated .d.ts.
Internal helpers updated to the same standard for consistency.
PolicyGroups typedef moved from policy-evaluator.js to policy-registry.js
(the module that produces it); evaluator imports it.
Verified: lint clean, types regenerated cleanly, 105/105 jest,
51/51 smoke, 11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration.
C005 (must-fix): added @throws annotations to every documented throw site. Public surface (wdkManager.registerPolicy, getAccount, getAccountByPath) declares both PolicyConfigurationError and the runtime PolicyViolationError that wrapped account methods may raise. Internal: PolicyEngine.register/applyPoliciesTo, validatePolicy, validateRegisterOptions, createPolicyEnforcedAccount. T002 (should-fix): removed the "defensive cloning" test that reached into _policyEngine._registry._project. The clone-defense behavior only becomes observable when Phase 2 ships engine-managed state; re-add then with a behavior-level assertion. T003 (should-fix): all 14 error-message assertions now use exact toBe() against zod's actual output, replacing the toMatch(/regex/) / toContain() property-based assertions. The two looped tests (conditionTimeoutMs and accounts entries) now declare a {value, message} pair per case so each iteration asserts a real value. Pinned to zod 4.4.3's wording. Verification: lint clean, types clean, 104/104 jest, 51/51 smoke, 11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration.
…-beta.10) Resolves conflicts in package.json, package-lock.json, wdk-manager.js, and types/src/wdk-manager.d.ts. Integrated from main: - Re-registration guard on registerWallet (da98ac9) - Account-decoration deduplication via _decoratedAccounts WeakSet (10020a8 + 353f3c4) — composes with policy enforcement: the proxy wraps the decorated account, decoration is idempotent across repeated getAccount calls - Swidge protocol support (634f28e) — registerProtocol + getSwidgeProtocol added; policy engine treats swidge ops the same as any other protocol method (Phase 2 will surface swidge in PROTOCOL_METHODS if needed) - bare-node-runtime ^1.4.0, @tetherto/wdk-wallet ^1.0.0-beta.9, jest 30.4.2 - OIDC publish workflow (e1272ee) - Version bump to 1.0.0-beta.10 PR-side preserved: - PolicyEngine wired into constructor alongside _decoratedAccounts - registerPolicy + applyPoliciesTo + dispose wiring intact - All policy typedef imports retained in wdk-manager.d.ts Verification: lint clean, types clean, 116/116 jest (104 ours + 12 from main), 51/51 smoke, 11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration.
Issue 1 (R21): PolicyEngine's class-level JSDoc block was sitting above the DEFAULT_CONDITION_TIMEOUT_MS const instead of the class itself, so tsc was attaching the docs to the wrong target. Moved the const above the JSDoc so it lands on the class. Issue 2: disposeWallet had stale "chain-bound" wording (chain was renamed to wallet long ago) and a tagless @param. Fixed both. Issue 3: five public typedefs (PolicyAction, PolicyScope, PolicyOperation, PolicyCondition, AccountIdentifier) had no descriptions. These ship in the generated .d.ts and show up as IDE hover-tooltips for consumers — added one-line descriptions to each. Issue 4 (functional, surfaced by the merge): SwidgeProtocol support landed in wdk-manager.js but the policy engine wasn't extended to match — PROTOCOL_METHODS lacked a swidge entry, OPERATIONS didn't include 'swidge', PolicyOperation typedef didn't list it, and PROTOCOL_GETTERS in the proxy didn't enumerate getSwidgeProtocol. Policy enforcement was silently bypassed on every swidge call. Added 'swidge' to OPERATIONS + PolicyOperation + PROTOCOL_METHODS, added [getSwidgeProtocol, swidge] to PROTOCOL_GETTERS, and added a regression test that registers a SwidgeProtocol subclass and asserts a DENY on the 'swidge' op blocks .swidge() through the proxy. Verification: lint clean, types clean, 117/117 jest (added the swidge regression), 51/51 smoke, 11/11 Bare e2e, 8/8 EVM, 6/6 ERC-4337.
CQ8b: replaced bare `catch {}` in snapshot() with a narrow filter that
only swallows DataCloneError (the documented structuredClone failure for
non-cloneable inputs) and rethrows anything else, so real bugs surface
instead of silently producing the raw value.
CQ3: dropped the redundant optional-chain on `.includes` in
collectReferencedOperations. After schema validation rule.operation is
always a string or a string array — both have `.includes` natively.
CQ5: introduced DUMMY_SWIDGE_RESULT alongside DUMMY_SWAP_RESULT and
DUMMY_BRIDGE_RESULT; the swidge regression test now uses the constant
instead of an inline object literal.
Style: replaced the ternary-used-as-statement in
collectReferencedOperations with a plain if/else.
Verification: lint clean, types clean, 117/117 jest, 51/51 smoke,
11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration.
jonathunne
left a comment
There was a problem hiding this comment.
A few JSDoc + test-convention nits from a pass with the WDK review skills — all low-priority and non-blocking.
… rename)
Main renamed src/wdk-manager.js → src/wdk.js (and the test + types) and
dropped "WdkManager" naming throughout in favor of "WDK". Brought the
rename onto this branch and propagated it through the policy work:
- tests/wdk-manager-policy.test.js → tests/wdk-policy.test.js
(file rename + WdkManager import alias → WDK, wdkManager variable →
wdk, describe('WdkManager — policy engine') → describe('WDK — policy engine'))
- index.js: kept our policy typedef re-exports; updated import path
to ./src/wdk.js
- AGENTS.md: kept the policy-engine section + the architecture
sentence that mentions the PolicyEngine; updated "WDK manager" to
"WDK" in the proxy-mutation note; updated test-file pointer
- JSDoc comments in policy-engine.js and policy-account-proxy.js:
"WDK manager" → "WDK"; "wdk-manager.js" → "wdk.js"
- types/* regenerated from the updated JSDoc
Verification: lint clean, types clean, 117/117 jest, 51/51 smoke,
11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration.
C4: introduced a shared WrapContext typedef in policy-engine.js for the two parallel sites (PolicyEngine.applyPoliciesTo + createPolicyEnforcedAccount) that take a wallet identifier + path/index + engine reference. Left policy-context.js's buildContext input as-is — its shape is materially different (per-call eval inputs, not per-account routing). C5: added block-level descriptions to all 10 object typedefs that previously opened straight into @Property tags — Policy, PolicyContext, PolicyRule, RegisterPolicyOptions, SimulationTraceEntry, SimulationResult (policy-engine.js); EvaluateOptions, Verdict (policy-evaluator.js); PolicyGroups (policy-registry.js); PolicyVerdict (policy-error.js). C6: added @returns description clauses to normalisePolicyWallet, formatPolicyError, formatRegisterOptionsError in policy-schemas.js (previously declared type but no clause). C7: flipped the 6 DUMMY_* placeholder hashes from `0x<thing>-dummy` to `0xdummy-<thing>` to match the prefix convention. Replaced the 4 placeholder addresses (RECIPIENT, SANCTIONED, SPENDER, TOKEN) with valid 0x+40hex shapes (repeating-digit pattern for role at a glance). C8: rolling-cap test now asserts the wrapper forwarded the original (un-cloned) tx through to the wallet — toHaveBeenCalledTimes(2) plus toHaveBeenNthCalledWith for each of the two successful sends. Verification: lint clean, types clean, 117/117 jest, 51/51 smoke, 11/11 Bare e2e, 8/8 EVM, 6/6 ERC-4337.
…etAccount Security finding (audit, high severity): signTransaction is a public method on IWalletAccount that returns a fully-signed transaction hex string ready to broadcast externally. It was not in OPERATIONS, so no policy — not even operation: '*' action: 'DENY' — could intercept it. A caller holding the policy-wrapped account could sign value-moving transactions and broadcast out-of-band, defeating the entire policy engine's value-movement guardrails. Added signTransaction to OPERATIONS and the PolicyOperation typedef. Verified the auditor's exact attack scenario now throws PolicyViolationError. Related issue uncovered while verifying: OPERATIONS listed signMessage and signHash, neither of which match an actual IWalletAccount method. The canonical wallet method is `sign(message)` — so policies targeting operation: 'signMessage' registered fine but never fired (silent no-op, worse than a hard error). signHash had the same problem. Renamed signMessage → sign in OPERATIONS / PolicyOperation / tests; removed signHash. Users who write operation: 'signMessage' or 'signHash' now get a clear PolicyConfigurationError instead of a silently-broken policy. Regression test pins both rejections. Documented the contract in AGENTS.md: every OPERATIONS entry must exactly match a method name on IWalletAccount or a registered protocol class; aspirational entries are forbidden because the schema accepts them while runtime silently ignores them. Test additions (5): DENY on signTransaction blocks the proxy method; wildcard * also catches signTransaction; ALLOW signTransaction forwards the original tx through; DENY on sign blocks proxy.sign; signMessage and signHash are now rejected at registration. Cross-repo follow-up (not in this PR): the BTC/TON/TRON/Solana wallet packages need the same audit — confirm every public signing or value-moving method has a matching OPERATIONS entry. EVM + ERC-4337 were verified in this commit. Verification: lint clean, types clean, 122/122 jest (added 5), 51/51 smoke, 11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration, auditor's attack scenario now blocked.
Security finding (audit, high severity): policies that named one
money-movement op were silently bypassable via every other op the
account exposed. The two compounding problems:
1. Proxy only wrapped methods named in registered rules — sibling
methods (sendTransaction with ERC-20 calldata, signTypedData for
Permit, delegate for ERC-7702, approve for spender-pull, raw
signTransaction) went straight to the underlying account.
2. Even where the proxy DID intercept, evaluate() returned ALLOW
for any operation no rule addressed.
Together: a "cap transfer at $100" policy did nothing about a
gigabyte of equivalent value being moved via any of the other write
or signing methods. The policy engine — the SDK's headline security
feature — had a permeable hole exactly where it mattered most.
Two-part fix:
PART 1 (policy-account-proxy.js + policy-engine.js):
- PolicyEngine gains _isGoverned(wallet, path, index) → true when
any policy applies to the (wallet, path, index) tuple.
- createPolicyEnforcedAccount now wraps every OPERATIONS method
that exists on the underlying account when the account is
governed (not just the ones referenced by rules). Same for
protocol getters: every PROTOCOL_METHODS entry gets wrapped on
the returned protocol instance.
- Accounts with no policies remain ungoverned — the proxy is not
applied, zero cost path preserved.
PART 2 (policy-evaluator.js):
- The 'not-governed' ALLOW path is gone. Replaced with a BLOCK
that carries reason 'no-applicable-rule'. This is the
default-deny semantic the engine needs to be the security
primitive it claims to be.
ESCAPE HATCH:
Users who want permissive semantics on a specific account register
a wildcard ALLOW baseline and layer specific DENYs on top:
rules: [
{ name: 'allow-all', operation: '*', action: 'ALLOW', conditions: [] },
{ name: 'block-bad', operation: 'sendTransaction', action: 'DENY',
conditions: [({ params }) => isSanctioned(params.to)] }
]
Documented in README and AGENTS.
TESTS:
+ 5 sibling-method regression tests covering the auditor's exact
attack scenarios:
• cap-transfer policy blocks sendTransaction with calldata
• cap-transfer policy blocks approve
• cap-transfer policy blocks signTypedData (Permit)
• cap-transfer policy blocks delegate (ERC-7702)
• ungoverned accounts skip the proxy entirely (zero-cost preserved)
+ 1 new wildcard-ALLOW opt-out test ("permissive baseline pattern")
~ 4 existing tests rewritten — their premise (default-allow on
unaddressed ops) is no longer true; the new assertions are
BLOCK with reason 'no-applicable-rule'.
VERIFICATION:
- lint clean, types clean
- 128/128 jest (was 122; +5 regressions +1 opt-out, -0 net after
rewriting 4 obsolete-semantic tests in place)
- 51/51 smoke (1 case in the smoke test was also testing
default-allow on a sibling op; updated to assert BLOCK)
- 11/11 Bare e2e, 8/8 EVM integration, 6/6 ERC-4337 integration
- .scripts/sec-bypass-repro.mjs runs all 5 auditor scenarios →
BLOCKED (no-applicable-rule); transfer 50 within cap → ALLOWED;
transfer 200 over cap → BLOCKED (governed-but-unmatched).
… code)
Tests review (tests/wdk-policy.test.js):
- R1: replaced 4 `not.toBeUndefined()` presence-checks with value-asserting
simulate-mirror calls that confirm both the mirror exists and the
verdict it returns. Two test bodies expanded in place.
- R2: added missing `toHaveBeenCalledWith` companions to 4 tests that
only had `toHaveBeenCalledTimes(N)` — multi-policy stacking, approve
nested-call escape, concurrent calls, bridge nested-call escape. The
condition contexts use `expect.objectContaining({ operation, wallet,
params })` since the engine controls the entire frozen context object;
wallet-side mocks (sendTransactionMock) use exact `toHaveBeenCalledWith`.
JSDoc/types review:
- R13: bare `{Object}` on `register()`'s `registrationContext` param
(policy-engine.js) — extracted to a `RegistrationContext` typedef.
- R13: bare `{Object}` on `buildContext()`'s `input` param
(policy-context.js) — extracted to a `BuildContextInput` typedef.
- R28 (stale docs after the default-deny refactor): `SimulationResult`
and `Verdict` typedef property descriptions still referenced the old
`not-governed` reason in policy-engine.js and policy-evaluator.js;
updated to use `no-applicable-rule`, which is the actual BLOCK reason
the evaluator emits when no rule addresses the operation.
- Dead-code removal: `collectReferencedOperations` (policy-validators.js)
and `_relevantOperations` (policy-engine.js) became unreferenced when
the proxy switched from "wrap only ops in rules" to "wrap every
OPERATIONS method on governed accounts" (commit 752f42a). Deleted
both, the now-unused import, and updated the PolicyEngine class JSDoc
to point at `_isGoverned` instead.
Types: corresponding `.d.ts` files were edited manually (no
`npm run build:types` per request) — RegistrationContext + BuildContextInput
added, _relevantOperations removed, collectReferencedOperations removed,
stale `not-governed` strings updated everywhere they appear.
Verification: lint clean, 128/128 jest, 51/51 smoke, 11/11 Bare e2e,
8/8 EVM integration, 6/6 ERC-4337 integration.
jonathunne
left a comment
There was a problem hiding this comment.
One follow-up from a wdk-type-check pass on ccc1b5e (ran the repo's pinned tsc 5.8.3).
…esses Jonathan review) In ccc1b5e the `Policy` typedef import in policy-validators.js was dropped alongside the dead `collectReferencedOperations` function — but `validatePolicy` still uses `@param {Policy}`. The hand-edited `policy-validators.d.ts` carries `export type Policy = …`, but a fresh `npm run build:types` (pinned tsc 5.8.3) regenerates it without that export, leaving the param type unresolved. Re-adding the typedef makes `build:types` reproduce the committed `policy-validators.d.ts` byte-for-byte (verified locally). Catch by jonathunne: #55 (comment)
Requested changes have been made
jonathunne
left a comment
There was a problem hiding this comment.
Looks great @AlonzoRicardo 👌
Summary
Phase 1 of the Local Transaction Policies engine, per the PRD. Policies are plain JS objects registered against the WDK instance; the engine wraps every write-facing operation on a wallet account (and on protocol getters) so DENY rules throw
PolicyViolationErrorbefore the underlying method runs and ALLOW rules pass through untouched.What's in the engine
override_broader_scopeto grant explicit exceptions for treasury wallets, etc.{ operation, chain, account: <readOnly>, params, args }. Stateful policies are supported in Phase 1 via closures over user-owned state.RegisterPolicyOptions.conditionTimeoutMs) so a stuck async condition cannot hang the wallet pipeline. On timeout the condition is treated the same as a throw.account.simulate.<method>(...)mirror runs evaluation without execution and returns{ decision, policy_id, matched_rule, reason, trace }.AsyncLocalStorage(per-async-chain), so concurrent calls evaluate independently while nested calls within one chain skip re-evaluation.node:async_hooksis loaded lazily so importing@tetherto/wdk/baredoes not fail at link time on runtimes that don't exposeAsyncLocalStorage; the error only surfaces if a policy is actually registered.PolicyConfigurationErrorsynchronously on unknown ops, missing fields, contradictory configuration, and unknown chain bindings — no soft-mode.sendTransaction, transfer, approve, signMessage, signHash, signTypedData, signAuthorization, delegate, revokeDelegation, swap, bridge, supply, withdraw, borrow, repay, buy, sell, *.Public API additions
In
index.js:PolicyViolationError,PolicyConfigurationError(classes).Policy,PolicyRule,PolicyCondition,PolicyContext,PolicyAction,PolicyScope,PolicyOperation,SimulationResult,SimulationTraceEntry,RegisterPolicyOptions.IWalletAccountReadOnlyfrom@tetherto/wdk-walletso condition functions can be typed without a direct parent dep.In
WDK:registerPolicy(chain?, policies, options?)(overloaded — chain string/array first, or policies first)._applyPoliciesslotted intogetAccountandgetAccountByPathbetween protocol registration and the return.dispose([chains])anddispose()clear policy bindings per-chain or globally.Phase 2 / 3 forward-compat
stateandonSuccessfields onPolicyRuleandRegisterPolicyOptionsare accepted at registration and ignored at runtime — reserved for Phase 2 (engine-managed state + post-execution hooks).stateis deep-cloned at registration so callers cannot mutate engine state post-registration.Policyshape — no SDK changes needed there.A dedicated forward-compat audit confirmed no Phase 1 design choice forces a breaking change in any later phase.
Test plan
npm test— 95/95 passing (38 existing + 57 new intests/wdk-manager-policy.test.js)npm run lint— clean (Standard style)npm run build:types— clean type generationtoBeInstanceOf/type checks); error catches unpackname,policyId,ruleName,reason,message; simulate results assertdecision,policy_id,matched_rule,reason,trace