feat(policy)!: deny-by-default proxy with central exclusion list - #82
Open
AlonzoRicardo wants to merge 7 commits into
Open
feat(policy)!: deny-by-default proxy with central exclusion list#82AlonzoRicardo wants to merge 7 commits into
AlonzoRicardo wants to merge 7 commits into
Conversation
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.
AlonzoRicardo
requested review from
Boka44,
Davi0kProgramsThings and
jonathunne
August 11, 2026 21:24
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 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 loudPolicyViolationErrorinstead 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 themwdk-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 namedget*and create remote state:getSingleUseDepositAddress— "Generates a single-use deposit address"getStaticDepositAddress— "generating one if it does not already exist"getStaticDepositAddress(singular) is a WRITE whilegetStaticDepositAddresses(plural) is a READ — one character apart, opposite classificationsAll 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
cleanupConnectionsas 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 makefinally { await account.cleanupConnections() }throw on any account without a matching ALLOW rule.Resulting list: 73 entries, source in
constants.jsmatches Section D exactly.Two consequences the brief didn't call out
wdk-core's own additions to the account had to be excluded.
_registerProtocolsinstallsregisterProtocoland sixget*Protocolgetters before the policy proxy wraps the account. Under deny-by-default the proxy sees them like any other callable, so without exclusionaccount.getSwapProtocol('velora')is denied and protocol access breaks entirely.toReadOnlyAccountis 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.
operationwas validated against theOPERATIONSenum. WithOPERATIONSdeleted and any method governable, a rule forpayLightningInvoicewould have failed registration while the engine denied every call to it — the feature would ship unusable.operationis now any non-empty string, andPolicyOperationisstring. 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
Append-only, flat, matched globally by method name. Non-string entries throw
PolicyConfigurationErrorat construction. Unknown names are accepted so an exclusion can precede the wallet release that introduces the method.registerPolicynow 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,
standardclean. Amethod coverageblock plus targeted regressions.Coverage: governed non-excluded method denies;
DEFAULT_POLICY_EXCLUSIONSmember 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 insimulate; 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:
0after wrap,1after an explicit read.account.sendTransaction === rawAccount.sendTransaction) rather than spying onevaluate. If no proxy exists the engine cannot be reached at all — a stronger claim than "evaluate wasn't called".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
payLightningInvoiceregisters and fires, and thesignMessage/signHashtest inverts to assert the typo now fails safe.Notes
.d.tshand-maintained; every declaration diffed againstmainand traced to an intended source change. The two known deviations intypes/src/wdk.d.tsare preserved.DEFAULT_POLICY_EXCLUSIONSis frozen because it ships publicly — in-place mutation would widen the exclusion set for every engine built afterwards in the process.(engine, prototype); own members are still collected fresh per instance, because the WDK installsregisterProtocoland the protocol getters directly onto each account and they must keep shadowing inherited ones. 20kgetLendingProtocolcalls: 47ms → 19ms.ungovernedBehaviorswitch, 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).main. Overlaps feat(policy): hint at catch-all pattern in no-match PolicyViolationError #80 inconstants.js,README.md,AGENTS.mdand the test file; expect conflicts whichever lands second and ping me for the rebase.Deliverables
docs/policy-exclusions-audit.mdcommitted, Sections A–E filled intetherto/wdk-*repo — 58, via the org endpoint, not the brief's 47.d.tshand-updated, not regenerated in place//comments added to method bodies