Skip to content

fix(policy): evaluate account bindings against the wrap-time path - #120

Merged
AlonzoRicardo merged 4 commits into
tetherto:mainfrom
Boka44:fix/freeze-policy-path
Oct 16, 2026
Merged

AlonzoRicardo merged 4 commits into
tetherto:mainfrom
Boka44:fix/freeze-policy-path

Conversation

@Boka44

@Boka44 Boka44 commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Description

Account-scope policies are resolved by the account's derivation path. Two gaps let a path-bound rule miss:

  1. The enforced proxy re-read account.path on every intercepted call, so a change to the account's path after the proxy was built could make an active rule stop matching.
  2. The wrapping decision re-read account.path on every retrieval. Because the wallet manager caches account instances, if the path changed after the account was first governed, a later getAccount() could read the changed path, decide the account is ungoverned, and hand back an unwrapped account.

Motivation and Context

  • Capture the derivation path when the proxy is created (the same value already used to decide whether to wrap) and store it on the enforcement context. Each intercepted call evaluates account-scope bindings against that captured path instead of re-reading account.path.
  • Record each account's path in a WeakMap the first time it is seen (in _applyPolicies), and use that first-seen value for the wrapping decision on every later retrieval. On a real account path is a prototype getter the proxy cannot stop from being reassigned, so this first-sight capture — rather than proxy set/defineProperty traps — is what keeps a later getAccount() from being handed a spoofed, unwrapped account.

Related Issue

Closes #101

Type of change

  • Bug fix (non-breaking change which fixes an issue)

@Boka44
Boka44 force-pushed the fix/freeze-policy-path branch from 3e4fbdb to 278651a Compare October 12, 2026 10:54
Two ways a path-bound rule could miss: the proxy re-read account.path on every call (a later change could dodge an active rule), and path was writable through the proxy (a spoofed path could make a future getAccount() skip the wrap). Capture the path at wrap time for evaluation, and refuse path/_path writes through the proxy.

Closes tetherto#101
@Boka44
Boka44 force-pushed the fix/freeze-policy-path branch from 278651a to 9d05a12 Compare October 12, 2026 11:03
@bookingminds

Copy link
Copy Markdown

Hi @Boka44, thanks for picking this up. This is the fix from my #112 (opened 4 Oct, closed today): the same branch name, the path captured at wrap time on the enforcement context, and the set / defineProperty traps that refuse path and _path.

If you go with this version, could you add me as a co-author on the commit?

Co-authored-by: bookingminds <236186253+bookingminds@users.noreply.github.com>

I ran the four attack tests from #112 (defineProperty, path =, _path = and a raw path change after wrapping) against this branch, and all of them pass. Happy to help with review.

Comment thread src/policy/policy-account-proxy.js Resolved
Comment thread src/policy/policy-account-proxy.js Resolved
path is a prototype getter on real accounts, so the set/defineProperty traps did not actually stop spoofing (reassignment, prototype swap, or returning the raw account from an added method) from making a later getAccount() skip the wrap. Record each raw account's path in a WeakMap the first time WDK sees it and evaluate against that. Covers the spoof routes and the simulate path.
@bookingminds

Copy link
Copy Markdown

Thanks @Boka44. e656146 covers what @AlonzoRicardo raised, and it is the same first-sight WeakMap I had on a branch built on your 9d05a12, so I'm not offering that code. Two gaps are still open in the tests:

  • The protocol simulate mirror has no test. This is simulate.getXProtocol(label).method, at policy-account-proxy.js:399. Changing it back to ctx.account.path still passes the suite.
  • The spoof tests only change the raw account directly. The routes from the review go through the handle: Object.setPrototypeOf(handle, …) and handle.__proto__ = ….

I added a test for each on top of your branch, in one commit that changes only tests: bookingminds@600ccf3

  • With your WeakMap reverted, the two handle tests fail.
  • With line 399 reverted, the simulate test fails.
  • The suite passes (189). Feel free to cherry-pick it.

One heads-up: e656146 removes the traps that #121 edits, so the two PRs now conflict in policy-account-proxy.js.

For the record, my #112 had the same gap that only the traps covered. Credit to @AlonzoRicardo for finding it.

Comment thread src/policy/policy-account-proxy.js Resolved
The .d.ts typedef was missing path and still described account as read
for the derivation path.
Add a test asserting simulate.getXProtocol evaluates against the
wrap-time path; it fails if the mirror reverts to re-reading account.path.
@AlonzoRicardo
AlonzoRicardo merged commit fc56715 into tetherto:main Oct 16, 2026
4 checks passed
jonathunne pushed a commit to jonathunne/wdk that referenced this pull request Nov 11, 2026
The policy engine is opt-in and imports nothing from the rest of core,
only zod, yet every install of @tetherto/wdk ships it. It moves to
tetherto/wdk-policies and is published as @tetherto/wdk-policies, a
plugin that attaches to a WDK instance. Core keeps one middleware hook
so the plugin can still govern every account the instance hands out.

Removed:

- src/policy/, types/src/policy/ and docs/policy-exclusions-audit.md.
- WDK#registerPolicy, WDK#getPolicyExclusions, the constructor options
  maxConditionTimeoutMs and policyExclusions, _applyPolicies, and the
  engine calls in dispose.
- From index.js: PolicyViolationError, PolicyConfigurationError,
  DEFAULT_POLICY_EXCLUSIONS, DENIAL_CODES and the policy typedefs.
- tests/wdk-policy.test.js, which moves with the engine.
- The policy sections of README.md and AGENTS.md, replaced by a
  pointer to the new package.
- zod from dependencies.

Changed, in _runMiddlewares:

- A middleware receives a second argument, { blockchain, index, path }.
  index is absent for getAccountByPath; path is the account's full
  derivation path.
- A middleware's non-nullish return value replaces the account. The
  next middleware, and the caller of getAccount, get the replacement.
- Middlewares run after _registerProtocols, where the engine used to
  wrap the account, so the account a middleware returns already has
  the protocol getters.

Kept from the engine fixes that landed before the move:

- tetherto#117: protocols are still built when a getter is called, through
  account._buildProtocol(category, label, subject). The plugin passes
  its governed account as the subject, so a protocol registered on the
  account is built with it. A protocol registered with
  wdk.registerProtocol is still built with the original account.
- tetherto#120: path is the one recorded the first time WDK saw the account,
  not a fresh read of account.path, so a later change to the account
  cannot change the path middlewares receive.

New typedef MiddlewareContext. MiddlewareFunction becomes
(account, context) => Promise<account | void>. The middleware tests
expect the context argument and gain two replacement cases, a
first-seen path case, and two cases for how _buildProtocol treats a
middleware's wrapper.

A middleware that returns a value by accident now replaces the
account.

Version 1.0.0-beta.21.

Refs tetherto#122
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.

Policy proxy does not freeze path, so an account-scope DENY can miss

3 participants