Skip to content

feat: add Phase 1 local transaction policy engine#55

Merged
jonathunne merged 24 commits into
mainfrom
feat/local-transaction-policies-phase-1
Jun 12, 2026
Merged

feat: add Phase 1 local transaction policy engine#55
jonathunne merged 24 commits into
mainfrom
feat/local-transaction-policies-phase-1

Conversation

@AlonzoRicardo

@AlonzoRicardo AlonzoRicardo commented May 4, 2026

Copy link
Copy Markdown
Contributor

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 PolicyViolationError before the underlying method runs and ALLOW rules pass through untouched.

What's in the engine

  • Three-group evaluation (account → wallet → project) with DENY-wins and account-level override_broader_scope to grant explicit exceptions for treasury wallets, etc.
  • Conditions are user-supplied functions, sync or async, with full access to a frozen context { operation, chain, account: <readOnly>, params, args }. Stateful policies are supported in Phase 1 via closures over user-owned state.
  • Per-condition timeout (default 30s, configurable via RegisterPolicyOptions.conditionTimeoutMs) so a stuck async condition cannot hang the wallet pipeline. On timeout the condition is treated the same as a throw.
  • Fail-closed DENY: a throwing or timing-out DENY condition is treated as matched, so an attacker cannot bypass a DENY by causing its backing service to fail. ALLOW rules retain fail-open-as-no-match.
  • account.simulate.<method>(...) mirror runs evaluation without execution and returns { decision, policy_id, matched_rule, reason, trace }.
  • Nested-call escape via AsyncLocalStorage (per-async-chain), so concurrent calls evaluate independently while nested calls within one chain skip re-evaluation. node:async_hooks is loaded lazily so importing @tetherto/wdk/bare does not fail at link time on runtimes that don't expose AsyncLocalStorage; the error only surfaces if a policy is actually registered.
  • Registration validation throws PolicyConfigurationError synchronously on unknown ops, missing fields, contradictory configuration, and unknown chain bindings — no soft-mode.
  • 17 supported operations + wildcard: 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).
  • Typedefs: Policy, PolicyRule, PolicyCondition, PolicyContext, PolicyAction, PolicyScope, PolicyOperation, SimulationResult, SimulationTraceEntry, RegisterPolicyOptions.
  • Re-export of IWalletAccountReadOnly from @tetherto/wdk-wallet so condition functions can be typed without a direct parent dep.

In WDK:

  • New chainable method registerPolicy(chain?, policies, options?) (overloaded — chain string/array first, or policies first).
  • New private hook _applyPolicies slotted into getAccount and getAccountByPath between protocol registration and the return.
  • dispose([chains]) and dispose() clear policy bindings per-chain or globally.

Phase 2 / 3 forward-compat

  • state and onSuccess fields on PolicyRule and RegisterPolicyOptions are accepted at registration and ignored at runtime — reserved for Phase 2 (engine-managed state + post-execution hooks). state is deep-cloned at registration so callers cannot mutate engine state post-registration.
  • Conditions are functions in Phase 1; widening the validator + evaluator to also accept Phase 3 DSL objects is purely additive (no breaking change for existing function-form policies).
  • Phase 4 portal is external tooling that produces/consumes the same Policy shape — 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 in tests/wdk-manager-policy.test.js)
  • npm run lint — clean (Standard style)
  • npm run build:types — clean type generation
  • All new tests go through the public WDK API only (no internal-module imports per WDK testing standards)
  • End-to-end smoke pass through stub wallets: project DENY blocks; account override beats project DENY; simulate doesn't execute; concurrent calls evaluate independently; nested calls escape correctly; protocol getters wrap write methods only; multi-chain registration; dispose semantics; condition timeout fires; throwing DENY fails closed
  • All assertions use concrete values (no toBeInstanceOf/type checks); error catches unpack name, policyId, ruleName, reason, message; simulate results assert decision, policy_id, matched_rule, reason, trace

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 claudiovb 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.

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: structuredClone fallback 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 AsyncLocalStorage since commit 7567876.

JSDoc, style, and types review to follow.

Comment thread src/policy/policy-account-wrapper.js Outdated
Comment thread src/policy/policy-evaluator.js Outdated
Comment thread src/policy/policy-registry.js Outdated
Comment thread src/policy/policy-evaluator.js
- 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).
Comment thread src/policy/policy-account-proxy.js Outdated
Comment thread src/policy/policy-account-proxy.js
Comment thread src/policy/policy-engine.js Outdated
Comment thread src/policy/policy-account-proxy.js Outdated
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.
Comment thread src/policy/constants.js Outdated
Comment thread src/policy/constants.js Outdated
Comment thread src/policy/constants.js Outdated
Comment thread src/policy/policy-validators.js Outdated
Comment thread src/policy/policy-validators.js Outdated
Comment thread src/policy/policy-error.js
Comment thread src/policy/policy-error.js Outdated
Comment thread src/policy/policy-error.js Outdated
Comment thread src/policy/policy-error.js Outdated
Comment thread src/policy/policy-engine.js
…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.
@socket-security

socket-security Bot commented May 21, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedzod@​4.4.310010010093100

View full report

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 jonathunne 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.

A few JSDoc + test-convention nits from a pass with the WDK review skills — all low-priority and non-blocking.

Comment thread src/policy/policy-context.js Outdated
Comment thread src/policy/policy-engine.js
Comment thread src/policy/policy-schemas.js Outdated
Comment thread tests/wdk-policy.test.js Outdated
Comment thread tests/wdk-policy.test.js
Comment thread tests/wdk-manager-policy.test.js Outdated
… 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 jonathunne 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.

One follow-up from a wdk-type-check pass on ccc1b5e (ran the repo's pinned tsc 5.8.3).

Comment thread src/policy/policy-validators.js
…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)
@jonathunne
jonathunne dismissed stale reviews from Davi0kProgramsThings and claudiovb June 12, 2026 04:49

Requested changes have been made

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

Looks great @AlonzoRicardo 👌

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.

4 participants