@lit-protocol/flows-policy
v0.3.1
Published
The Flows policy + approval engine — pure, deterministic one-gate evaluator. Two hosts: the Flows backend (pre-flight) and the CID-pinned Lit Action (authoritative seat).
Readme
@lit-protocol/flows-policy
The Flows policy + approval engine: a pure, deterministic "one-gate" evaluator plus the intent-hashing, JWS-approval, step-up, and EVM-UserOp primitives the gate is built from. No I/O, no clock, no DB — every input arrives as a fact, so the same bytes produce the same verdict everywhere.
Why this package exists (the two-host invariant)
A Flows fund-movement decision must be made in two places and they must agree:
- The Flows backend runs
evaluate()as an advisory pre-flight ("would a live run be allowed?") so the UI can show a verdict before anything signs. - The CID-pinned Lit Action runs the authoritative seat inside the TEE — it is the only host whose verdict can release a signature.
If those two ran different code, the preview would lie. So this package is the single
source of truth: the backend imports it directly, and the action embeds the compiled
bytes of a narrow entry (src/action-userop.ts) as an inlined IIFE (the TEE has no
module resolution at execution). A CI sync test fails if the committed action bundle
drifts from a fresh build. Same source → same compiled logic → identical verdicts.
This is also why you can build your own gated Action on top of these helpers and get the exact policy semantics the platform enforces — see Build your own gated Action below.
Install
npm install @lit-protocol/flows-policyInside this monorepo the package is consumed via a
file:link ("@lit-protocol/flows-policy": "file:packages/policy"in the rootpackage.json), so changes are picked up without re-publishing. Runnpm run buildinpackages/policy/(ornpm run build:packagesat the root) to refreshdist/.
Quickstart — the one gate
evaluate() takes an intent (what wants to move), the org policy (the law), the
day's spend state, and the action's capability manifest, and returns a Verdict:
allow | allow_with_notify | hold | deny, each with a human-readable trace.
import { evaluate, type Intent, type PolicyDoc, type SpendState, type CapabilityManifest } from '@lit-protocol/flows-policy'
const NOW = '2026-06-22T12:00:00.000Z'
const policy: PolicyDoc = {
schema: 'flows-policy-v1',
orgId: 'org_1',
version: 1,
caps: { maxNotionalPerOrderUsd: 2_000, maxNotionalPerDayUsd: 8_000 },
circuits: { withdrawalsAlwaysHold: true, holdAssurance: 'L2', holdTtlSec: 86_400, policyLoosening: 'hold' },
}
const intent: Intent = {
schema: 'flows-intent-v1',
intentId: 'int_1',
orgId: 'org_1',
initiator: { id: 'dev_1', class: 'human', role: 'OPERATOR' },
verb: 'trade-spot',
params: { symbol: 'BTC/USDT', side: 'buy', qty: 0.01 },
notionalUsd: 1_000,
symbol: 'BTC/USDT',
reason: 'rebalance',
mode: 'live',
createdAt: NOW,
}
const spend: SpendState = { schema: 'flows-spend-state-v1', date: '2026-06-22', orgDayUsd: 0 }
const manifest: CapabilityManifest = { schema: 'flows-capability-manifest-v1', verbs: ['trade-spot'] }
const verdict = evaluate({ intent, policy, policyHash: 'sha256-of-jcs(policy)', spend, manifest, now: NOW })
console.log(verdict.kind) // 'allow' (a $1,000 trade under a $2,000/order, $8,000/day cap)Push the same intent to notionalUsd: 5_000 and the verdict becomes hold (over the
per-order cap → needs a second person); switch initiator.class to 'agent' with no
agent allowance and it denys as initiator.agent.unleashed. See
examples/policy-gated-action/ for a runnable version of all three.
Verifying the policy hash
The gate trusts the policy doc you hand it; the host is responsible for proving that doc is the registered one. Compute the canonical hash with the same helper the registry uses:
import { sha256Jcs } from '@lit-protocol/flows-policy'
const policyHash = sha256Jcs(policy) // sha256 over RFC 8785 (JCS) canonical JSONPublic surface
| Export | What it does |
| --- | --- |
| evaluate(input) | The one-gate decision. Pure; returns a Verdict. |
| classifyPolicyEdit(from, to) | 'tighten' vs 'loosen' — drives instant-apply vs hold/timelock. |
| verifyApproval(opts) | Email-approval-v2 JWS gate (assurance L1–L3). |
| sha256Jcs(v), intentHashFor(intent), canonicalize(v) | RFC 8785 canonicalization + the portable intent/policy hashing both hosts bind with. |
| signJws, verifyJws, addressFromPrivateKey | Detached-payload JWS used for approvals/attestations. |
| verifyStepUp, signStepUpEoa, stepUpDigest | Step-up (re-auth) proof verification. |
| executionIdempotencyKey(...) | Deterministic dedupe key for a single execution. |
| gateYieldUserOp(...), validateEvmYieldCalls(...) | The EVM smart-account (ZeroDev Kernel) UserOp signing gate — the bytes the sign-userop action embeds. |
| yieldUserIntentHash, yieldUserIntentChallenge, verifyYieldUserIntentProof | Bind a yield UserOp to a signed user intent. |
| computeUserOpHashV07, decodeKernelExecute, keccak256, functionSelector, decodeExecutionBatch, decodeExecutionSingle | EVM/UserOp primitives the gate is built from. |
| AAVE_V3_POOLS, ENTRY_POINT_V07 | Address constants. |
All types are exported alongside the functions (Intent, PolicyDoc, Verdict,
SpendState, CapabilityManifest, GranteeAllowance, EvaluateInput, …).
Per-grantee leashes (standing authorizations)
Beyond the org-wide caps and the per-class initiatorClassAllowances, the gate enforces a
named-grantee leash when the host passes one. The host looks up an ACTIVE, non-expired
StandingAuthorization for the authenticated initiator (member / agent / API key) and
passes its bounds in as granteeAllowance, with requireGranteeAllowance: true to fail
closed if no grant matched:
const verdict = evaluate({
intent, policy, policyHash, spend, manifest, now: NOW,
requireGranteeAllowance: true, // this initiator MUST act under a grant
granteeAllowance: { // the matched grant's leash
caps: { maxNotionalPerOrderUsd: 500, maxNotionalPerDayUsd: 500 },
verbAllowlist: ['trade-spot'],
venueAllowlist: ['binance'],
spentTodayUsd: 0,
},
})The leash is enforced as a most-restrictive bound (∩ org ∩ install) — it can only
narrow, never widen. A defined-but-empty allowlist ([]) is deny-all on that axis (this
is how two disjoint grants intersect to deny-all rather than allow-all).
Build your own gated Action
The TEE runs a single source blob and CIDs it — there is no import at execution time. To
reuse this engine inside your own Lit Action you inline the compiled gate as an IIFE,
exactly like lit-actions/wallet/sign-userop.js does:
- Add (or reuse) a narrow entry under
packages/policy/src/that re-exports only the gate functions your action needs — seeaction-userop.ts. Keeping it narrow lets esbuild tree-shake the rest (no@noble/curves, nojwt/approval) so the action stays small. - Bundle it to an IIFE that exposes a
FlowsPolicyglobal and inline it into a thin shim at a// __FLOWS_POLICY_IIFE__marker. The repo'sscripts/build-wallet-actions.mjsdoes this;packages/policy/scripts/bundle.mjsemits a genericdist/flows-policy.iife.js(+ ESM) with an SRI hash for the CDN-import path. - In your action, call the global:
const verdict = FlowsPolicy.evaluate({ ... })and only sign whenverdict.kind === 'allow'.
Because the action embeds the same bytes the backend imports, your pre-flight preview and your in-TEE seat can never disagree.
Scripts
npm run build # tsc → dist/ (the importable package)
npm run bundle # esbuild → dist/flows-policy.iife.js + .mjs (+ SRI hashes)
npm test # vitest (evaluate, approval, stepup, userOp, idempotency, canonicalize, seat)
npm run typecheck # tsc --noEmitDesign notes
- Pure & deterministic. No network, no
Date.now()in the decision path —nowis an input. This is what lets the verdict be reproduced and attested. - Fail closed. Unpriceable notional, a missing required grant, an unverifiable axis, or
an unavailable screening all resolve to
deny/hold, never a silent allow. - Intersection semantics. The manifest, org policy, install bounds, and grantee leash all narrow; nothing a host passes can widen what the policy permits.
License: MIT.
