Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 3 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,6 @@ Source code must be strictly typed using JSDoc comments to support the `build:ty

## Repository Specifics
- **Domain:** Core Orchestrator.
- **Role:** Central entry point for the WDK. Manages lifecycle of multiple wallet instances, protocols, and transaction policies.
- **Key Pattern:** Dependency Injection (registerWallet, registerProtocol, registerPolicy).
- **Architecture:** `WDK` class manages a collection of `WalletManager` instances and a `PolicyEngine` that intercepts write-facing operations on every account returned from `getAccount` / `getAccountByPath`.

## Policy Engine
- Source lives under `src/policy/`. Public surface is the `PolicyViolationError` and `PolicyConfigurationError` classes, the `DEFAULT_POLICY_EXCLUSIONS` and `DENIAL_CODES` constants, and the `Policy*` / `SimulationResult` / `DenialCode` typedefs re-exported from `index.js`. Everything else under `src/policy/` is internal.
- The engine wraps account methods and protocol getters at `getAccount` time. When any policy applies to an account ("governed"), the proxy wraps **every callable** on the account and its prototype chain except the names in `DEFAULT_POLICY_EXCLUSIONS` (`src/policy/constants.js`) unioned with the consumer's `policyExclusions`. Coverage is deny-by-default: an unknown method is governed, not skipped. `docs/policy-exclusions-audit.md` is the source of truth for the default list — regenerate it (org endpoint, not `/users/tetherto/repos`, which hides private protocol packages) whenever a wallet or protocol package adds a public method. Combined with the default-deny evaluator (below) this closes sibling-method bypasses (e.g. a "cap transfer" policy cannot be sidestepped by `sendTransaction({ to: token, data: ERC-20-transfer-calldata })`, `approve` to an attacker, off-chain Permit via `signTypedData`, or ERC-7702 `delegate`).
- **Default-deny on governed accounts.** `policy-evaluator.evaluate()` returns BLOCK with `reason: 'no-applicable-rule'` whenever an operation reaches it and no rule addresses that operation. To opt into permissive semantics on a specific account, register a wildcard ALLOW baseline (`operation: '*', action: 'ALLOW'`) and layer specific DENYs on top. Accounts with no registered policies are not governed and skip the proxy entirely (zero cost).
- Every BLOCK verdict carries a `code` from `DENIAL_CODES` (`src/policy/constants.js`) — `RULE_DENIED`, `NO_APPLICABLE_RULE`, or `GOVERNED_BUT_UNMATCHED` — which surfaces as `PolicyViolationError.code` and, for the same context, as `SimulationResult.code`. It is the stable discriminator; `reason` holds consumer-authored rule text on the `RULE_DENIED` path and must not be switched on. The two default-deny codes are also what `policy-error.js` keys the explanatory message off: those errors carry the catch-all ALLOW snippet, while `RULE_DENIED` keeps the terse `Policy violation: <policy>/<rule>: <reason>` form. Adding a denial path means adding a code, not string-matching `reason`. `buildEnforcedMethod` in `policy-account-proxy.js` is the sole construction site of `PolicyViolationError`; the verdict's `code` and the intercepted method name (`operation`) are threaded through there, so a new construction site elsewhere must thread both too.
- **Operation name = canonical method name.** A rule's `operation` is validated as any non-empty string, because any callable can be governed. A name that matches nothing still registers and never fires — but the real method stays governed, so a typo now fails safe (loud denial) instead of opening a hole. Adding a write method to a wallet package needs no change here; only adding a *read* does, and it belongs in `DEFAULT_POLICY_EXCLUSIONS` after re-running the audit.
- Two scopes only: `project` (with optional wallet restriction declared via the policy's `wallet` field) and `account` (with required `wallet` field and a per-account `accounts` list of paths and/or integer indexes). The `wallet` value is the same string passed to `registerWallet` — an opaque consumer-chosen key, not a blockchain identifier. `wallet` accepts a single string or a non-empty array of strings; omitting it on a project-scope policy applies it across every registered wallet. DENY wins across scopes; account-scope `ALLOW` rules can short-circuit project-scope DENYs via `override_broader_scope: true`.
- Index-form `accounts` entries match the index passed to `wdk.getAccount(wallet, index)`. They do not match accounts retrieved via `getAccountByPath` because the wallet manager has no synchronous path → index resolver. Path-form entries match either retrieval style.
- Nested-call escape works via a `Proxy` (see `src/policy/policy-account-proxy.js`), not an async-context marker. The engine returns a Proxy that exposes enforced versions of write methods; the proxy's `get` trap binds non-enforced methods to the underlying account, so internal `this.method()` calls and protocol code that holds a direct reference to the account naturally bypass enforcement. The policy engine itself does not mutate the account (the WDK's `_registerProtocols` step does install `registerProtocol` / `getXProtocol` helpers before the proxy is built — separate, pre-existing concern). The same code path runs on every JavaScript runtime that supports `Proxy` — no `AsyncLocalStorage`, no runtime detection, no Bare-specific fallback.
- Enforcement applies to the **surface of the proxy** only. Underscore-prefixed fields (e.g. `protocol._account`) reach the raw account and bypass enforcement by design — they are private by convention and not part of the supported surface. The same is true for protocol methods that internally call account operations via their stored `this._account` (e.g. `bridge.bridge(...)` calling `this._account.sendTransaction(...)`); this is the documented nested-call escape.
- `proxy.registerProtocol(...)` is special: the underlying helper returns the raw account, so the `get` trap rewrites the return value to be the proxy itself. Without this rewrite, `proxy.registerProtocol(...).sendTransaction(...)` would skip enforcement.
- Each condition is raced against `conditionTimeoutMs` (default 30s, settable per `registerPolicy` call via `RegisterPolicyOptions`). The timeout is **per-policy**: it is resolved at registration time, clamped to the engine ceiling, and stored on the registry record, so registration order never changes the timeout another policy is evaluated under. The ceiling is `maxConditionTimeoutMs` (default 30s), set once via `new WDK(seed, options)`; a policy requesting more is capped, not rejected. A condition that throws or times out is fail-closed for DENY rules (treated as matched → block) and fail-open-as-no-match for ALLOW rules.
- Conditions are user-supplied functions in Phase 1. The engine accepts `state` and `onSuccess` rule fields for Phase 2 (engine-managed state + post-execution hooks) but ignores them at runtime; `state` is deep-cloned at registration so callers can't mutate engine state post-registration.
- Tests live in `tests/wdk-policy.test.js` and exercise the engine exclusively through the public WDK API.
- **Role:** Central entry point for the WDK. Manages lifecycle of multiple wallet instances and protocols.
- **Key Pattern:** Dependency Injection (registerWallet, registerProtocol, registerMiddleware).
- **Architecture:** `WDK` class manages a collection of `WalletManager` instances. Middlewares run on every account returned from `getAccount` / `getAccountByPath` and may replace it; transaction policies live in `@tetherto/wdk-policies`, which installs itself that way.
Loading
Loading