Skip to content

feat(policy)!: deny-by-default proxy with central exclusion list - #82

Open
AlonzoRicardo wants to merge 7 commits into
mainfrom
feat/policy-deny-by-default-exclusions
Open

feat(policy)!: deny-by-default proxy with central exclusion list#82
AlonzoRicardo wants to merge 7 commits into
mainfrom
feat/policy-deny-by-default-exclusions

Conversation

@AlonzoRicardo

@AlonzoRicardo AlonzoRicardo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

The engine governed a fixed 22-method list. A wallet method absent from it reached the signer with no evaluation and no error — the policy silently did not apply. For a policy engine that is the worst available failure mode: the guardrail looks installed and isn't.

The audit quantifies it. 28 write methods across three packages were invisible to the engine: 12 on Spark (payLightningInvoice, paySparkInvoice, claimDeposit, createLightningInvoice, …), 14 on the Safe multisig account (executeTx, addOwner, changeThreshold, …), 2 on Aave (setUserEMode, setUseReserveAsCollateral).

Change

Coverage is inverted. The proxy wraps every callable on the account and its prototype chain, and consults an exclusion set — DEFAULT_POLICY_EXCLUSIONS ∪ options.policyExclusions, resolved once at construction. Unknown methods are governed, so the worst case is a loud PolicyViolationError instead of an unpoliced transfer.

Phase 1: the audit is the deliverable

docs/policy-exclusions-audit.md, Sections A–E. Two findings change the result, so please read the doc rather than just the list.

1. The brief's enumeration command silently under-samples the org. It specifies:

gh api /users/tetherto/repos --paginate --jq '.[].name'

That returns public repos only — 113 total, 47 wdk-*. The org endpoint returns 128 / 58. The 11 missing repos include four private packages with real protocol surface, among them wdk-protocol-multisig-safe, which contributes 16 writes and 19 reads and is the most write-dense account class in the org. Auditing from the public list would have shipped a default list missing 8 reads and left those 16 writes unexamined. The doc records the correct command; anyone re-running this must use it.

2. A get* prefix carries no information about whether a method mutates. Three Spark methods are named get* and create remote state:

  • getSingleUseDepositAddress"Generates a single-use deposit address"
  • getStaticDepositAddress"generating one if it does not already exist"
  • and the trap: getStaticDepositAddress (singular) is a WRITE while getStaticDepositAddresses (plural) is a READ — one character apart, opposite classifications

All three stay governed. Any name-shaped heuristic for building this list would have put at least the first two into the defaults and reintroduced the exact bug this PR fixes.

No classification collisions. Every name appearing in more than one package classifies identically everywhere; the widely-shared ones (withdraw, dispose, getAddress, getTransfers, proposeMessage, swap/bridge) were each checked individually. So no legitimate read had to be withheld on collision grounds.

One deviation from the brief, flagged. Its Section E example lists cleanupConnections as consumer-append, while its own Step 1.3 rule lists it as a LIFECYCLE candidate for the defaults. Those contradict. I followed Step 1.3 — the normative rule: closing connections moves no value and gates nothing an attacker benefits from, while governing it would make finally { await account.cleanupConnections() } throw on any account without a matching ALLOW rule.

Resulting list: 73 entries, source in constants.js matches Section D exactly.

Two consequences the brief didn't call out

wdk-core's own additions to the account had to be excluded. _registerProtocols installs registerProtocol and six get*Protocol getters before the policy proxy wraps the account. Under deny-by-default the proxy sees them like any other callable, so without exclusion account.getSwapProtocol('velora') is denied and protocol access breaks entirely. toReadOnlyAccount is worse than that: the engine calls it to build the condition context, so governing it deadlocks evaluation against itself. All eight are in the default list, called out in Section D.

The rule schema had to be loosened. operation was validated against the OPERATIONS enum. With OPERATIONS deleted and any method governable, a rule for payLightningInvoice would have failed registration while the engine denied every call to it — the feature would ship unusable. operation is now any non-empty string, and PolicyOperation is string. This touches validation, not evaluator or rule semantics, so it stays inside the stated non-goals — but it is a public type widening and worth a look.

The upside: a typo'd operation name used to be a registration error. Now it registers, never fires, and the real method stays governed — so a typo fails safe instead of opening a hole. There's a test for exactly that.

Public API

import WDK, { DEFAULT_POLICY_EXCLUSIONS } from '@tetherto/wdk'

const wdk = new WDK(SEED, { policyExclusions: ['syncWalletBalance'] })
wdk.getPolicyExclusions()  // frozen readonly string[] — defaults ∪ yours

Append-only, flat, matched globally by method name. Non-string entries throw PolicyConfigurationError at construction. Unknown names are accepted so an exclusion can precede the wallet release that introduces the method.

registerPolicy now rejects a rule that names an excluded method. { operation: 'getTransfers', action: 'DENY' } used to register cleanly and could never fire — the proxy never wraps an excluded method, so the rule was unreachable. That is the same fail-open class this PR exists to remove, relocated to the exclusion side, so it throws at registration instead. The wildcard stays exempt: * means "every governed operation", which is by definition the set that excludes these names. Unknown and misspelled names are still accepted — those fail safe, because the real method stays governed.

Tests

178 passing, standard clean. A method coverage block plus targeted regressions.

Coverage: governed non-excluded method denies; DEFAULT_POLICY_EXCLUSIONS member executes untouched; consumer exclusion executes untouched; duplicate across both lists resolves once; empty and omitted both equal the defaults; non-string entries rejected; unknown name accepted; getPolicyExclusions() frozen and non-mutating; no-policies short-circuit; inherited method governed; accessor not intercepted; excluded-method rule rejected (single and inside an array); wildcard not treated as excluded; protocol method outside the old verb list governed and mirrored in simulate; governed sync method returns a promise; own accessor shadowing a prototype method not invoked at wrap time; two engines with different exclusion sets resolving the same class differently.

Three worth singling out:

  • The accessor test asserts the getter is never invoked, not merely that it isn't wrapped. Classification reads property descriptors, so a getter with side effects is never triggered by wrapping. The test counts reads and asserts 0 after wrap, 1 after an explicit read.
  • The short-circuit test asserts proxy identity (account.sendTransaction === rawAccount.sendTransaction) rather than spying on evaluate. If no proxy exists the engine cannot be reached at all — a stronger claim than "evaluate wasn't called".
  • Resolved-set sizes are hardcoded (73 / 74) rather than derived from DEFAULT_POLICY_EXCLUSIONS.length. Deriving them would let a test pass while someone silently deleted entries from the audited list.

Three pre-existing tests were migrated off the inclusion model: two unknown-operation rejection tests now assert empty-string rejection and that payLightningInvoice registers and fires, and the signMessage/signHash test inverts to assert the typo now fails safe.

Notes

  • .d.ts hand-maintained; every declaration diffed against main and traced to an intended source change. The two known deviations in types/src/wdk.d.ts are preserved.
  • DEFAULT_POLICY_EXCLUSIONS is frozen because it ships publicly — in-place mutation would widen the exclusion set for every engine built afterwards in the process.
  • The inherited half of the prototype walk is memoised per (engine, prototype); own members are still collected fresh per instance, because the WDK installs registerProtocol and the protocol getters directly onto each account and they must keep shadowing inherited ones. 20k getLendingProtocol calls: 47ms → 19ms.
  • No statics on wallet classes, no per-wallet keys, no derivation from the readonly hierarchy, no ungovernedBehavior switch, no wildcards in the exclusion list, no removal API, no companion wallet PRs, no evaluator changes, no error-message changes (that's feat(policy): hint at catch-all pattern in no-match PolicyViolationError #80).
  • No version bump — release branches do that.
  • Branched off main. Overlaps feat(policy): hint at catch-all pattern in no-match PolicyViolationError #80 in constants.js, README.md, AGENTS.md and the test file; expect conflicts whichever lands second and ping me for the rebase.

Deliverables

  • Phase 1: docs/policy-exclusions-audit.md committed, Sections A–E filled in
  • Section A lists every tetherto/wdk-* repo — 58, via the org endpoint, not the brief's 47
  • Section B classifies every public method on every account/protocol class
  • Section C reports collisions — none, plus three recorded traps
  • Section D contains the proposed list (73 entries), sorted
  • Section E contains the consumer-append snippet (Spark only)
  • Phase 2: source list matches Section D exactly
  • All acceptance criteria met; 178 tests pass; lint clean
  • .d.ts hand-updated, not regenerated in place
  • No inline // comments added to method bodies

Removes the static OPERATIONS inclusion list. The proxy now wraps every
callable on registered wallet accounts, walking the prototype chain, and
consults an exclusion set that is the union of:

  - DEFAULT_POLICY_EXCLUSIONS (curated list built from an org-wide audit
    of every wdk-wallet-* and wdk-* protocol package under the tetherto
    namespace; see docs/policy-exclusions-audit.md)
  - options.policyExclusions (consumer append at WDK instantiation)

Fixes the silent-bypass class of bug where write methods absent from the
inclusion list (e.g. Spark's payLightningInvoice) were invisible to the
engine. The audit found 28 such methods across Spark, the Safe multisig
account and Aave. Unknown methods now default to governed (loud denial
via default-deny) instead of silently unpoliced.

A rule's operation is now validated as any non-empty string rather than
against a fixed enum, since any governed method must be addressable.

Short-circuits when no policies are registered: the proxy is not applied
at all, so ungoverned use costs nothing.

Breaking: consumers who registered policies against the old inclusion
model may see additional method calls now going through the engine. Add
wallet-specific reads to policyExclusions to restore previously
unpoliced behavior, or register ALLOW rules for those methods.
A rule naming an excluded method registered cleanly and could never
fire — the proxy never wraps an excluded method, so a well-formed DENY
provably did nothing. That is the same fail-open class deny-by-default
exists to remove, moved to the exclusion side. registerPolicy now
rejects it; the wildcard stays exempt because it means "every governed
operation".

The engine also exposed its exclusion Set through a public getter, so
any holder could add 'sendTransaction' at runtime and silently drop
enforcement while getPolicyExclusions() kept reporting the stale
pre-mutation list. Replaced with an isExcluded() predicate over a
single private Set; getExclusions() now derives its frozen array on
demand, so the two views cannot drift.

Also: the simulate mirror derived protocol methods from the hardcoded
PROTOCOL_METHODS verb list while the enforced proxy governed the whole
surface, so methods like Aave's setUserEMode were blocked by the engine
but missing from simulate — the mirror now resolves the protocol and
enumerates the same surface. Method resolution binds the descriptor
found during classification instead of re-reading through [[Get]], so
an own accessor shadowing a prototype method is no longer invoked at
wrap time.

Docs: corrected the audit's entry count (73, not 71), recorded the
non-WDK-base-class limitation, documented that governed calls are async
and their arguments must be structured-cloneable, and refreshed the
stale OPERATIONS references in AGENTS.md and the test comments.
…ehind

PROTOCOL_METHODS lost its last consumer when the simulate mirror started
enumerating the live protocol surface, and PROTOCOL_GETTERS' type tags
existed only to index it — both were still exported and iterated, so the
linter saw them as used. Leaving the hardcoded verb list in place invites
someone to wire it back up, which is the thing this PR argues against.

Also: sort the constants import into the alphabetical relative block, move
assertRulesAddressGovernedOperations below the class to match how sibling
modules place private helpers, name the 'policyExclusions' option in the
rejection message, and record why DEFAULT_POLICY_EXCLUSIONS is frozen —
it is a public export, so in-place mutation would widen the exclusion set
for every engine built afterwards in the process.
collectGovernedMethods re-walked the full prototype chain on every
getAccount and every getXProtocol(label). The protocol getter is the
idiomatic per-operation call shape and builds a fresh instance each
time, so `getLendingProtocol('aave').supply(...)` paid for a descriptor
enumeration across two prototypes plus a Map build on every call.

The inherited half of that walk is a pure function of the prototype
chain and the engine's exclusion set, and the exclusion set is fixed at
construction — so it memoises cleanly, keyed by (engine, prototype)
through nested WeakMaps. Own members are still collected fresh per
instance: they vary, because the WDK installs registerProtocol and the
protocol getters directly onto each account, and they must keep
shadowing inherited ones.

20k getLendingProtocol calls: 47ms -> 19ms (2.4us -> 1.0us per call).

Cached functions are stored unbound and bound per instance at the call
site, so two accounts sharing a class cannot leak each other's binding.
A regression test covers the invariant the cache could plausibly break:
two engines with different exclusion sets governing instances of the
same class must resolve differently.
Three descriptions carried design rationale rather than contract: why
isExcluded is a predicate instead of the backing Set, that rejecting an
excluded-method rule closes the same failure class as deny-by-default,
and that re-walking the prototype chain used to dominate getXProtocol.
All three explain a decision to whoever edits the code next, which git
blame serves better than a .d.ts shipped to consumers — the commits that
made those changes already carry the reasoning. The behavioural halves
stay: accessors are never invoked, the wildcard is exempt, and the cache
key is a pure function of the chain and the exclusion set.

Also splits three @throws tags that had merged three or four distinct
conditions each into one string, and replaces the "prototype - The
prototype to start from" tautology with what the parameter is for and
what the short-circuit values are.

The engine and WDK constructors document the same validator, so their
tags are split identically and stay in sync.
The engine and WDK constructors document the same options bag. The last
commit synced their @throws tags but left the @PARAM descriptions
divergent — WDK named policyExclusions, the engine did not.
The exclusion-immutability test reached into wdk._policyEngine to assert
the Set getter was gone and isExcluded existed. That pins the shape of a
refactor rather than a contract, and reaching past the public API is what
the rule against it exists to prevent. Stripping those two lines left the
test fully subsumed by the frozen-array test above it, which already
covers the same claim through getPolicyExclusions, so it is removed
rather than renamed.

Worth recording: absence of a mutation API is not observable from
outside it. The frozen-array test proves the returned value cannot be
used to mutate engine state; nothing can prove from the public surface
that no other handle exists. Better an honest gap than a green test
implying otherwise.

Also: hardcode the resolved-set sizes instead of deriving them from
DEFAULT_POLICY_EXCLUSIONS, so dropping entries from the constant fails
the suite instead of moving it; assert policy_id and matched_rule on the
simulation result; and hoist the reused stub returns to DUMMY_ constants,
prefixing the single-use ones.
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