diff --git a/src/chains/registry.ts b/src/chains/registry.ts index 05e6daf..4a3cf03 100644 --- a/src/chains/registry.ts +++ b/src/chains/registry.ts @@ -30,6 +30,7 @@ import type { ChainReadRelay } from "../daemon/chainRelay.js"; import type { PolicyReader } from "../daemon/chainPolicyReader.js"; import type { OnboardingBackend } from "../onboarding/flow.js"; import type { KeystoreBackend } from "../keystore/backend.js"; +import type { PolicyDialect } from "../core/policy/dialect.js"; /** Endpoint + pinned chain id — what every network-touching factory needs. */ export interface ChainWiring { @@ -59,6 +60,9 @@ export interface ChainModule { */ readonly registryLocatorPattern: RegExp; + /** The chain's policy dialect (#45): evaluation + validation vocabulary. */ + readonly dialect: PolicyDialect; + /** Normalize + validate a raw unserialized JSON transaction (INV-014). */ decode(input: unknown, context: ChainContext): DecodedTransaction; diff --git a/src/chains/xpr/dialect.ts b/src/chains/xpr/dialect.ts new file mode 100644 index 0000000..d60d02b --- /dev/null +++ b/src/chains/xpr/dialect.ts @@ -0,0 +1,101 @@ +/** + * The XPR policy dialect (issue #45, C.2) — the Antelope vocabulary that used + * to be hardcoded in the core engine and resolver, now behind PolicyDialect: + * + * - match paths: contract / action / authorization.(actor|permission) / + * data.* (single-authorization model, actor → accountIdentifier); + * - assets: the "1.0000 XPR" grammar (parseAsset) and the normalizer's + * data.quantity = { amount, symbol, precision } shape; + * - recipient: data.to; + * - provider: xpr.rpc.tableRow → get_table_rows bounded on the primary key. + * + * The shared patterns come from core/policy/vocabulary.ts — the SAME module + * the web editor imports, so daemon, validator and editor stay in lockstep. + */ + +import { AmbiguousValueError } from "../../core/errors.js"; +import { parseAsset, parseBareAmount, type AssetAmount } from "../../core/asset.js"; +import { + CHAIN_ID_PATTERN, + MATCH_PATH_PATTERN, + SELECT_FIELD_PATTERN, +} from "../../core/policy/vocabulary.js"; +import type { DecodedAction } from "../../core/types.js"; +import type { DialectRelay, PolicyDialect } from "../../core/policy/dialect.js"; +import type { ProviderEvidence, ProviderQuery } from "../../core/policy/engine.js"; + +export const xprDialect: PolicyDialect = { + matchPathPattern: MATCH_PATH_PATTERN, + selectFieldPattern: SELECT_FIELD_PATTERN, + chainIdPattern: CHAIN_ID_PATTERN, + providerNamespace: "xpr.rpc.tableRow", + + resolvePath(action: DecodedAction, path: string): unknown { + if (path === "contract") return action.contract; + if (path === "action") return action.action; + if (path === "authorization.actor") return action.authorization[0]?.accountIdentifier; + if (path === "authorization.permission") return action.authorization[0]?.permission; + if (path.startsWith("data.")) { + let current: unknown = action.data; + for (const segment of path.slice(5).split(".")) { + if (typeof current !== "object" || current === null || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; + } + // Unknown paths are rejected by the schema; reaching this is a logic error. + throw new AmbiguousValueError(`unresolvable match path: ${path}`); + }, + + /** + * The normalizer produces data.quantity = { amount, symbol, precision }; + * a rule with limits applied to an action without it is an ambiguity. + */ + assetOf(action: DecodedAction): AssetAmount { + const quantity = action.data["quantity"]; + if ( + typeof quantity === "object" && + quantity !== null && + typeof (quantity as Record)["amount"] === "string" && + typeof (quantity as Record)["symbol"] === "string" && + typeof (quantity as Record)["precision"] === "number" + ) { + const q = quantity as { amount: string; symbol: string; precision: number }; + const bare = parseBareAmount(q.amount); + if (bare.precision !== q.precision) { + throw new AmbiguousValueError("normalized quantity precision mismatch"); + } + return { units: bare.units, symbol: q.symbol, precision: q.precision }; + } + throw new AmbiguousValueError("rule has limits but the action carries no comparable asset"); + }, + + recipientOf(action: DecodedAction): string | undefined { + const to = action.data["to"]; + return typeof to === "string" ? to : undefined; + }, + + parseAssetLimit: parseAsset, + + async resolveProviderQuery(query: ProviderQuery, relay: DialectRelay): Promise { + // xpr.rpc.tableRow → a single row bounded on the primary key. + const params = { + code: query.args.contract, + scope: query.args.scope, + table: query.args.table, + lower_bound: query.args.key, + upper_bound: query.args.key, + limit: 1, + json: true, + }; + const result = await relay.call("get_table_rows", params); + const rows = (result as { rows?: unknown }).rows; + if (!Array.isArray(rows)) return { ok: false }; + const row = rows[0]; + if (row === undefined) return { ok: true, found: false, row: null }; + if (row === null || typeof row !== "object" || Array.isArray(row)) return { ok: false }; + return { ok: true, found: true, row: row as Record }; + }, +}; diff --git a/src/chains/xpr/module.ts b/src/chains/xpr/module.ts index 9ef941a..adcf67b 100644 --- a/src/chains/xpr/module.ts +++ b/src/chains/xpr/module.ts @@ -16,6 +16,7 @@ import { XprTransactionBroadcaster } from "./broadcaster.js"; import { XprChainReadRelay } from "./relay.js"; import { XprPolicyReader } from "./policyReader.js"; import { XprOnboardingBackend } from "./onboarding.js"; +import { xprDialect } from "./dialect.js"; import type { ChainModule, ChainWiring } from "../registry.js"; import type { KeystoreBackend } from "../../keystore/backend.js"; @@ -31,6 +32,8 @@ export const xprModule: ChainModule = { chainIdPattern: /^[0-9a-f]{64}$/, registryLocatorPattern: /^[a-z1-5.]{1,12}$/, + dialect: xprDialect, + decode(input: unknown, context: ChainContext): DecodedTransaction { return decodeXprTransaction(input, context); }, diff --git a/src/cli/daemonRunner.ts b/src/cli/daemonRunner.ts index 98cbfb1..aebcb2b 100644 --- a/src/cli/daemonRunner.ts +++ b/src/cli/daemonRunner.ts @@ -72,15 +72,16 @@ export async function startDaemonFromConfig( const quotas = new QuotaJournal(config.stateDbPath); const policyReader = overrides.policyReader ?? chainModule.createPolicyReader(wiring, config.signboxContract); - const policyCache = new PolicyCache(config.stateDbPath, policyReader, {}, overrides.now); + const policyCache = new PolicyCache(config.stateDbPath, policyReader, chainModule.dialect, {}, overrides.now); const audit = new AuditLog(config.stateDbPath); const decode = chainModule.decode.bind(chainModule); + const dialect = chainModule.dialect; const daemon = new SignBoxDaemon( { socketPath: config.socketPath, adminSocketPath: config.adminSocketPath }, overrides.now === undefined - ? { decode, signer, broadcaster, relay, quotas, policyCache, audit } - : { decode, signer, broadcaster, relay, quotas, policyCache, audit, now: overrides.now }, + ? { decode, dialect, signer, broadcaster, relay, quotas, policyCache, audit } + : { decode, dialect, signer, broadcaster, relay, quotas, policyCache, audit, now: overrides.now }, ); const wipeAll = (): void => keystore.wipe(); diff --git a/src/cli/index.ts b/src/cli/index.ts index bc55bef..157395d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -227,6 +227,7 @@ tx.command("explain") options.network !== undefined ? { network: options.network } : {}, ); const context = chainContextOf(config); + const dialect = getChain(config.chain).dialect; // Resolve the policy to evaluate. Default: the on-chain policy is the // source of truth (INV-004). Override: a local --policy file lets an @@ -238,7 +239,7 @@ tx.command("explain") let source: string; let meta: Record = {}; if (options.policy !== undefined) { - policy = validatePolicy(readJsonFile(options.policy)); + policy = validatePolicy(readJsonFile(options.policy), dialect); agentPermission = options.permission ?? "active"; policyVersion = Number(options.policyVersion ?? "1"); source = "local-file"; @@ -257,7 +258,7 @@ tx.command("explain") } // Same integrity gate the daemon cache applies (§8.6): hash + // canonical JCS + schema. A tampered row is refused, never dry-run. - const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash); + const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash, dialect); if (!verified.ok) { fail(`on-chain policy failed integrity check: ${verified.reason}`); } @@ -274,6 +275,7 @@ tx.command("explain") agentPermission, chainId: context.chainId, policyVersion, + dialect, }; // Resolve any providers (§8.4) the same way the daemon does, so the // dry-run matches: read them through the same read-only relay. @@ -286,6 +288,7 @@ tx.command("explain") endpoints: config.endpoints, chainId: config.chainId, }), + dialect, ) : undefined; const result = evaluatePolicy( diff --git a/src/core/policy/dialect.ts b/src/core/policy/dialect.ts new file mode 100644 index 0000000..6178e57 --- /dev/null +++ b/src/core/policy/dialect.ts @@ -0,0 +1,74 @@ +/** + * PolicyDialect (issue #45, C.2) — the chain-specific VOCABULARY of the + * policy engine, extracted behind an interface so the skeleton (deny-first + * control flow, JCS canonicalization, limits model, fail-closed pipeline) + * stays chain-agnostic and byte-identical on every chain. + * + * A dialect owns exactly what varies per chain: + * - which match paths exist and how they resolve against a decoded action + * (XPR: `authorization.actor|permission`, `data.*`); + * - the asset grammar of limit bounds ("1.0000 XPR") and how an action's + * comparable asset is extracted (XPR: `data.quantity`); + * - which field names the recipient for per-recipient quotas (XPR: `data.to`); + * - the deterministic provider namespace and how a resolved query maps to a + * read-only relay call (XPR: `xpr.rpc.tableRow` → `get_table_rows`). + * + * Implementations live in src/chains//dialect.ts and are exposed + * through the ChainModule (#44). The engine receives the dialect through the + * EvaluationContext; the validator takes it as a parameter — a policy is + * always validated in ITS chain's dialect (INV-013). + */ + +import type { DecodedAction } from "../types.js"; +import type { AssetAmount } from "../asset.js"; +import type { ProviderEvidence, ProviderQuery } from "./engine.js"; + +/** Structural view of the daemon's read-only relay (no daemon import here). */ +export interface DialectRelay { + call(method: string, params: unknown): Promise; +} + +export interface PolicyDialect { + /** + * Closed match-path vocabulary, as a schema-embeddable pattern. Drives BOTH + * the validator (propertyNames) and the editor's compiler. + */ + readonly matchPathPattern: string; + + /** Provider `select` field names (spec §8.4). */ + readonly selectFieldPattern: string; + + /** The chain's chain-id format (embedded in the policy document). */ + readonly chainIdPattern: string; + + /** The deterministic provider namespace this dialect serves. */ + readonly providerNamespace: string; + + /** + * Resolve a match path against a decoded action. `undefined` means absent; + * an unresolvable path throws AmbiguousValueError (schema-validated paths + * only reach this on logic errors — fail closed). + */ + resolvePath(action: DecodedAction, path: string): unknown; + + /** + * The action's comparable asset, for value limits. Throws + * AmbiguousValueError when the action carries none (a rule with limits on + * such an action is a refusal, never a pass). + */ + assetOf(action: DecodedAction): AssetAmount; + + /** The action's recipient for per-recipient quotas, if any. */ + recipientOf(action: DecodedAction): string | undefined; + + /** Parse a limit bound written in the chain's asset grammar. */ + parseAssetLimit(text: string): AssetAmount; + + /** + * Resolve one provider query through the read-only relay (spec §8.4). The + * generic resolver wraps this with the timeout and the fail-closed catch — + * implementations just map the query to the chain's read call and shape + * the row. + */ + resolveProviderQuery(query: ProviderQuery, relay: DialectRelay): Promise; +} diff --git a/src/core/policy/engine.ts b/src/core/policy/engine.ts index c5f3090..57c24c0 100644 --- a/src/core/policy/engine.ts +++ b/src/core/policy/engine.ts @@ -17,19 +17,16 @@ import type { DecodedAction, DecodedTransaction, Decision, DenyCode } from "../types.js"; import type { MatchValue, Policy, PolicyRule, TableRowProvider } from "./schema.js"; +import type { PolicyDialect } from "./dialect.js"; import { AmbiguousValueError, AssetError, ProviderUnavailableError } from "../errors.js"; import { canonicalize } from "../canonical/jcs.js"; -import { - compareBareAmounts, - parseAsset, - parseBareAmount, - type AssetAmount, -} from "../asset.js"; +import { compareBareAmounts, parseBareAmount, type AssetAmount } from "../asset.js"; /** Resolved arguments of a table-row provider query. */ export interface ProviderQuery { key: string; - provider: "xpr.rpc.tableRow"; + /** The dialect's provider namespace (XPR: "xpr.rpc.tableRow"). */ + provider: string; args: { contract: string; scope: string; table: string; key: string }; } @@ -46,6 +43,8 @@ export interface EvaluationContext { agentPermission: string; chainId: string; policyVersion: number; + /** The chain's policy dialect (#45) — path resolution, asset grammar, providers. */ + dialect: PolicyDialect; /** * Resolved provider evidence (§8.4). The daemon resolves the queries listed * by collectProviderQueries() and passes them here. A rule whose provider has @@ -94,25 +93,6 @@ function substitute(value: string, ctx: EvaluationContext): string { } } -function resolvePath(action: DecodedAction, path: string): unknown { - if (path === "contract") return action.contract; - if (path === "action") return action.action; - if (path === "authorization.actor") return action.authorization[0]?.accountIdentifier; - if (path === "authorization.permission") return action.authorization[0]?.permission; - if (path.startsWith("data.")) { - let current: unknown = action.data; - for (const segment of path.slice(5).split(".")) { - if (typeof current !== "object" || current === null || Array.isArray(current)) { - return undefined; - } - current = (current as Record)[segment]; - } - return current; - } - // Unknown paths are rejected by the schema; reaching this is a logic error. - throw new AmbiguousValueError(`unresolvable match path: ${path}`); -} - /** Ordered comparison. Strings must be bare amounts of equal precision; safe integers are compared as integers. */ function compareOrdered(actual: unknown, expected: string): -1 | 0 | 1 { if (typeof actual === "string") { @@ -161,7 +141,7 @@ function predicateHolds(actual: unknown, expected: MatchValue, ctx: EvaluationCo /** The static part of a rule: its `match` field predicates only (no providers). */ function staticMatch(action: DecodedAction, rule: PolicyRule, ctx: EvaluationContext): boolean { for (const [path, expected] of Object.entries(rule.match)) { - if (!predicateHolds(resolvePath(action, path), expected, ctx)) return false; + if (!predicateHolds(ctx.dialect.resolvePath(action, path), expected, ctx)) return false; } return true; } @@ -175,7 +155,7 @@ function substituteArg(value: string, action: DecodedAction, ctx: EvaluationCont if (!value.startsWith("$")) return value; if (value === "$agent") return ctx.agent; if (value === "$agentPermission") return ctx.agentPermission; - const resolved = resolvePath(action, value.slice(1)); + const resolved = ctx.dialect.resolvePath(action, value.slice(1)); if (typeof resolved !== "string") { throw new AmbiguousValueError(`provider variable "${value}" did not resolve to a string`); } @@ -262,30 +242,6 @@ function ruleMatches(action: DecodedAction, rule: PolicyRule, ctx: EvaluationCon return true; } -/** - * Extract the action's normalized asset (produced by the ChainAdapter - * normalizer as data.quantity = { amount, symbol, precision }). A rule with - * limits applied to an action without a comparable asset is an ambiguity. - */ -function actionAsset(action: DecodedAction): AssetAmount { - const quantity = action.data["quantity"]; - if ( - typeof quantity === "object" && - quantity !== null && - typeof (quantity as Record)["amount"] === "string" && - typeof (quantity as Record)["symbol"] === "string" && - typeof (quantity as Record)["precision"] === "number" - ) { - const q = quantity as { amount: string; symbol: string; precision: number }; - const bare = parseBareAmount(q.amount); - if (bare.precision !== q.precision) { - throw new AmbiguousValueError("normalized quantity precision mismatch"); - } - return { units: bare.units, symbol: q.symbol, precision: q.precision }; - } - throw new AmbiguousValueError("rule has limits but the action carries no comparable asset"); -} - function deny(code: DenyCode, safeReason: string, policyVersion?: number): Decision { return policyVersion === undefined ? { effect: "deny", code, safeReason } @@ -354,10 +310,10 @@ export function evaluatePolicy( const limits = governing.limits; if (limits !== undefined) { - const asset = actionAsset(action); + const asset = ctx.dialect.assetOf(action); if (limits.maxPerTransaction !== undefined) { - const cap = parseAsset(limits.maxPerTransaction); + const cap = ctx.dialect.parseAssetLimit(limits.maxPerTransaction); // The cap is per symbol; an action of a different symbol under a // value cap would go uncapped — refuse rather than let it through. if (asset.symbol !== cap.symbol || asset.precision !== cap.precision) { @@ -376,15 +332,15 @@ export function evaluatePolicy( limits.maxCountPerDay !== undefined || limits.maxCountPerRecipientPerHour !== undefined; if (wantsWindow) { - const to = action.data["to"]; + const recipient = ctx.dialect.recipientOf(action); const demand: QuotaDemand = { ruleId: governing.id, amount: asset }; - if (typeof to === "string") demand.recipient = to; - if (limits.maxPerHour !== undefined) demand.maxPerHour = parseAsset(limits.maxPerHour); - if (limits.maxPerDay !== undefined) demand.maxPerDay = parseAsset(limits.maxPerDay); + if (recipient !== undefined) demand.recipient = recipient; + if (limits.maxPerHour !== undefined) demand.maxPerHour = ctx.dialect.parseAssetLimit(limits.maxPerHour); + if (limits.maxPerDay !== undefined) demand.maxPerDay = ctx.dialect.parseAssetLimit(limits.maxPerDay); if (limits.cooldownPerRecipientMs !== undefined) { demand.cooldownPerRecipientMs = limits.cooldownPerRecipientMs; if (demand.recipient === undefined) { - throw new AmbiguousValueError("cooldownPerRecipientMs requires a string data.to"); + throw new AmbiguousValueError("cooldownPerRecipientMs requires a recipient on the action"); } } if (limits.maxCountPerHour !== undefined) demand.maxCountPerHour = limits.maxCountPerHour; @@ -392,7 +348,7 @@ export function evaluatePolicy( if (limits.maxCountPerRecipientPerHour !== undefined) { demand.maxCountPerRecipientPerHour = limits.maxCountPerRecipientPerHour; if (demand.recipient === undefined) { - throw new AmbiguousValueError("maxCountPerRecipientPerHour requires a string data.to"); + throw new AmbiguousValueError("maxCountPerRecipientPerHour requires a recipient on the action"); } } quotaDemands.push(demand); diff --git a/src/core/policy/onchain.ts b/src/core/policy/onchain.ts index ec41026..7807994 100644 --- a/src/core/policy/onchain.ts +++ b/src/core/policy/onchain.ts @@ -15,6 +15,7 @@ import { createHash } from "node:crypto"; import { canonicalize } from "../canonical/jcs.js"; import { validatePolicy, type Policy } from "./schema.js"; +import type { PolicyDialect } from "./dialect.js"; export type PolicyIntegrityError = | "hash_mismatch" @@ -31,7 +32,11 @@ export type PolicyIntegrityResult = * then validate the schema. Returns the parsed policy on success, or a * structured reason the caller maps to its own failure mode. */ -export function verifyStoredPolicy(policyjson: string, policyhash: string): PolicyIntegrityResult { +export function verifyStoredPolicy( + policyjson: string, + policyhash: string, + dialect: PolicyDialect, +): PolicyIntegrityResult { const computed = createHash("sha256").update(Buffer.from(policyjson, "utf8")).digest("hex"); if (computed !== policyhash.toLowerCase()) return { ok: false, reason: "hash_mismatch" }; @@ -45,7 +50,7 @@ export function verifyStoredPolicy(policyjson: string, policyhash: string): Poli let policy: Policy; try { - policy = validatePolicy(parsed); + policy = validatePolicy(parsed, dialect); } catch { return { ok: false, reason: "schema_invalid" }; } diff --git a/src/core/policy/schema.ts b/src/core/policy/schema.ts index 4089b2f..d4c21c2 100644 --- a/src/core/policy/schema.ts +++ b/src/core/policy/schema.ts @@ -8,13 +8,8 @@ import { Ajv, type ValidateFunction } from "ajv"; import { ValidationError } from "../errors.js"; -import { parseAsset } from "../asset.js"; -import { - CHAIN_ID_PATTERN, - MATCH_PATH_PATTERN, - RULE_ID_PATTERN, - SELECT_FIELD_PATTERN, -} from "./vocabulary.js"; +import { RULE_ID_PATTERN } from "./vocabulary.js"; +import type { PolicyDialect } from "./dialect.js"; export type MatchOperator = | { lte: string } @@ -37,7 +32,8 @@ export type MatchValue = string | MatchOperator; * action being evaluated. `scope` defaults to `contract` when omitted. */ export interface TableRowProvider { - provider: "xpr.rpc.tableRow"; + /** The dialect's provider namespace (validated per-dialect at load time). */ + provider: string; args: { contract: string; scope?: string; table: string; key: string }; /** Field of the fetched row to test (single level). */ select: string; @@ -134,7 +130,11 @@ const matchValueSchema = { ], } as const; -const policyJsonSchema = { +/** The policy JSON Schema for one dialect — the skeleton is shared, the + * vocabularies (match paths, chain id, select fields, provider namespace) + * come from the dialect (#45). */ +function policyJsonSchemaFor(dialect: PolicyDialect) { + return { type: "object", additionalProperties: false, required: ["schemaVersion", "default", "chain", "rules"], @@ -148,7 +148,7 @@ const policyJsonSchema = { required: ["name", "chainId"], properties: { name: { type: "string", minLength: 1, maxLength: 32 }, - chainId: { type: "string", pattern: CHAIN_ID_PATTERN }, + chainId: { type: "string", pattern: dialect.chainIdPattern }, }, }, rules: { @@ -165,7 +165,7 @@ const policyJsonSchema = { type: "object", minProperties: 1, maxProperties: 32, - propertyNames: { pattern: MATCH_PATH_PATTERN }, + propertyNames: { pattern: dialect.matchPathPattern }, additionalProperties: matchValueSchema, }, limits: { @@ -191,7 +191,7 @@ const policyJsonSchema = { additionalProperties: false, required: ["provider", "args", "select", "op", "value"], properties: { - provider: { const: "xpr.rpc.tableRow" }, + provider: { const: dialect.providerNamespace }, args: { type: "object", additionalProperties: false, @@ -203,7 +203,7 @@ const policyJsonSchema = { key: { type: "string", minLength: 1, maxLength: 64 }, }, }, - select: { type: "string", pattern: SELECT_FIELD_PATTERN }, + select: { type: "string", pattern: dialect.selectFieldPattern }, op: { enum: ["contains", "eq"] }, value: { type: "string", minLength: 1, maxLength: 256 }, }, @@ -213,17 +213,29 @@ const policyJsonSchema = { }, }, }, -} as const; + } as const; +} const ajv = new Ajv({ strict: true, allErrors: false }); -const validateSchema: ValidateFunction = ajv.compile(policyJsonSchema); +// One compiled validator per dialect (keyed by its vocabulary fingerprint). +const validators = new Map(); +function validatorFor(dialect: PolicyDialect): ValidateFunction { + const key = `${dialect.matchPathPattern}\u0000${dialect.chainIdPattern}\u0000${dialect.selectFieldPattern}\u0000${dialect.providerNamespace}`; + let validate = validators.get(key); + if (validate === undefined) { + validate = ajv.compile(policyJsonSchemaFor(dialect)); + validators.set(key, validate); + } + return validate; +} /** * Validate a policy document. Throws ValidationError on ANY deviation: * unknown field, duplicate rule id, unparsable limit asset, deny rule with * limits. Returns the typed policy on success. */ -export function validatePolicy(input: unknown): Policy { +export function validatePolicy(input: unknown, dialect: PolicyDialect): Policy { + const validateSchema = validatorFor(dialect); if (!validateSchema(input)) { const detail = validateSchema.errors?.[0]; throw new ValidationError( @@ -251,7 +263,7 @@ export function validatePolicy(input: unknown): Policy { const raw = limits[field]; if (raw !== undefined) { try { - parseAsset(raw); + dialect.parseAssetLimit(raw); } catch { throw new ValidationError(`rule "${rule.id}": ${field} is not a valid asset string`); } diff --git a/src/daemon/policyCache.ts b/src/daemon/policyCache.ts index fb216b5..a0bd311 100644 --- a/src/daemon/policyCache.ts +++ b/src/daemon/policyCache.ts @@ -22,6 +22,7 @@ import Database from "better-sqlite3"; import { validatePolicy, type Policy } from "../core/policy/schema.js"; import { verifyStoredPolicy } from "../core/policy/onchain.js"; +import type { PolicyDialect } from "../core/policy/dialect.js"; import type { PolicyReader } from "./chainPolicyReader.js"; export interface CachedPolicy { @@ -85,6 +86,7 @@ export class PolicyCache { constructor( dbPath: string, private readonly reader: PolicyReader, + private readonly dialect: PolicyDialect, options: PolicyCacheOptions = {}, private readonly now: () => number = Date.now, ) { @@ -136,7 +138,7 @@ export class PolicyCache { // Integrity gate (§8.6): hash + canonical JCS + schema. The exact same // check the CLI dry-run applies, so the two can never disagree. - const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash); + const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash, this.dialect); if (!verified.ok) return { ok: false, reason: verified.reason }; const policy: Policy = verified.policy; @@ -189,7 +191,7 @@ export class PolicyCache { if (row === undefined) return undefined; let policy: Policy; try { - policy = validatePolicy(JSON.parse(row.policyjson)); + policy = validatePolicy(JSON.parse(row.policyjson), this.dialect); } catch { return undefined; } diff --git a/src/daemon/providerResolver.ts b/src/daemon/providerResolver.ts index 5e60071..fbb18c4 100644 --- a/src/daemon/providerResolver.ts +++ b/src/daemon/providerResolver.ts @@ -10,6 +10,7 @@ */ import type { ChainReadRelay } from "./chainRelay.js"; +import type { PolicyDialect } from "../core/policy/dialect.js"; import type { ProviderEvidence, ProviderEvidenceMap, ProviderQuery } from "../core/policy/engine.js"; const DEFAULT_TIMEOUT_MS = 3000; @@ -17,12 +18,13 @@ const DEFAULT_TIMEOUT_MS = 3000; export async function resolveProviders( queries: ProviderQuery[], relay: ChainReadRelay | undefined, + dialect: PolicyDialect, timeoutMs: number = DEFAULT_TIMEOUT_MS, ): Promise { const evidence: ProviderEvidenceMap = {}; await Promise.all( queries.map(async (query) => { - evidence[query.key] = await resolveOne(query, relay, timeoutMs); + evidence[query.key] = await resolveOne(query, relay, dialect, timeoutMs); }), ); return evidence; @@ -31,27 +33,14 @@ export async function resolveProviders( async function resolveOne( query: ProviderQuery, relay: ChainReadRelay | undefined, + dialect: PolicyDialect, timeoutMs: number, ): Promise { if (relay === undefined) return { ok: false }; try { - // V1 provider: xpr.rpc.tableRow → a single row bounded on the primary key. - const params = { - code: query.args.contract, - scope: query.args.scope, - table: query.args.table, - lower_bound: query.args.key, - upper_bound: query.args.key, - limit: 1, - json: true, - }; - const result = await withTimeout(relay.call("get_table_rows", params), timeoutMs); - const rows = (result as { rows?: unknown }).rows; - if (!Array.isArray(rows)) return { ok: false }; - const row = rows[0]; - if (row === undefined) return { ok: true, found: false, row: null }; - if (row === null || typeof row !== "object" || Array.isArray(row)) return { ok: false }; - return { ok: true, found: true, row: row as Record }; + // The QUERY→read-call mapping is the dialect's (#45); the timeout and the + // fail-closed catch are generic and stay here. + return await withTimeout(dialect.resolveProviderQuery(query, relay), timeoutMs); } catch { return { ok: false }; } diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 02b68cb..6e0b4dd 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -34,6 +34,7 @@ import type { import type { Policy } from "../core/policy/schema.js"; import { evaluatePolicy, collectProviderQueries } from "../core/policy/engine.js"; import { resolveProviders } from "./providerResolver.js"; +import type { PolicyDialect } from "../core/policy/dialect.js"; import { ValidationError } from "../core/errors.js"; import { parseSignRequest, @@ -100,6 +101,8 @@ export type AdminResponse = export interface DaemonDependencies { /** ChainAdapter decode seam (INV-014 enforcement lives there). */ decode: (input: unknown, context: ChainContext) => DecodedTransaction; + /** The chain's policy dialect (#45) — evaluation vocabulary. */ + dialect: PolicyDialect; /** Path-1 signing seam (§5.5). Called only after an allow decision. */ signer: TransactionSigner; /** @@ -557,10 +560,13 @@ export class SignBoxDaemon { agentPermission: runtime.permission, chainId: chain.chainId, policyVersion: activeVersion, + dialect: this.deps.dialect, }; const queries = collectProviderQueries(decoded, activePolicy, baseCtx); const evidence = - queries.length > 0 ? await resolveProviders(queries, this.deps.relay) : undefined; + queries.length > 0 + ? await resolveProviders(queries, this.deps.relay, this.deps.dialect) + : undefined; // Deterministic policy evaluation. const { decision, quotaDemands } = evaluatePolicy( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4d6ef17..6d98803 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -130,13 +130,14 @@ export function buildMcpServer(config: SignBoxConfig, options: McpOptions): McpS if (computed !== row.policyhash || canonicalize(JSON.parse(row.policyjson)) !== row.policyjson) { return ok({ decision: { effect: "deny", code: "POLICY_UNAVAILABLE" } }); } - const policy = validatePolicy(JSON.parse(row.policyjson)); + const policy = validatePolicy(JSON.parse(row.policyjson), chainModule.dialect); const decoded = chainModule.decode(transaction, context); const { decision } = evaluatePolicy(decoded, policy, { agent, agentPermission: meta.permission, chainId: config.chainId, policyVersion: row.version, + dialect: chainModule.dialect, }); return ok({ decision, note: "dry run — stateful quotas are enforced at sign time" }); } catch (error) { diff --git a/test/daemon-audit.test.ts b/test/daemon-audit.test.ts index bb32500..1aefe6a 100644 --- a/test/daemon-audit.test.ts +++ b/test/daemon-audit.test.ts @@ -13,6 +13,7 @@ import type { SignedTransactionResult, TransactionSigner, } from "../src/core/types.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -46,7 +47,7 @@ function policy() { match: { contract: "eosio.token", action: "transfer", "data.from": "$agent" }, }, ], - }); + }, xprDialect); } function request(to = "alice"): string { @@ -77,7 +78,7 @@ function makeDaemon(): { daemon: SignBoxDaemon; audit: AuditLog } { const audit = new AuditLog(join(mkdtempSync(join(tmpdir(), "signbox-audit-")), "state.db")); const daemon = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "s.sock") }, - { decode: decodeXprTransaction, signer: new FakeSigner(), audit, now: () => NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer: new FakeSigner(), audit, now: () => NOW }, ); daemon.registerAgent({ agent: "superagent", diff --git a/test/daemon-broadcast.test.ts b/test/daemon-broadcast.test.ts index 153b8de..68056ff 100644 --- a/test/daemon-broadcast.test.ts +++ b/test/daemon-broadcast.test.ts @@ -14,6 +14,7 @@ import type { SignedTransactionResult, TransactionSigner, } from "../src/core/types.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -50,7 +51,7 @@ function statefulPolicy() { limits: { maxPerTransaction: "1000.0000 XPR", maxPerHour: "2500.0000 XPR" }, }, ], - }); + }, xprDialect); } class FakeSigner implements TransactionSigner { @@ -111,7 +112,7 @@ function build(outcome: BroadcastOutcome): { const broadcaster = new FakeBroadcaster(outcome); const daemon = new SignBoxDaemon( { socketPath: join(dir, "signbox.sock") }, - { decode: decodeXprTransaction, signer: new FakeSigner(), broadcaster, quotas, now: () => NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer: new FakeSigner(), broadcaster, quotas, now: () => NOW }, ); const runtime: AgentRuntime = { agent: "superagent", diff --git a/test/daemon-cache.test.ts b/test/daemon-cache.test.ts index 44cc564..8a7c899 100644 --- a/test/daemon-cache.test.ts +++ b/test/daemon-cache.test.ts @@ -16,6 +16,7 @@ import type { SignedTransactionResult, TransactionSigner, } from "../src/core/types.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -105,10 +106,10 @@ describe("daemon with on-chain policy cache (§14)", () => { function makeDaemon(reader: PolicyReader): SignBoxDaemon { signer = new FakeSigner(); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); const daemon = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "s.sock") }, - { decode: decodeXprTransaction, signer, policyCache: cache, now: () => NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer, policyCache: cache, now: () => NOW }, ); // The registered policy is a deny-all placeholder: the cache must override // it with the on-chain policy, or nothing would ever be allowed. diff --git a/test/daemon-read.test.ts b/test/daemon-read.test.ts index b1d1aa4..c8e3ca6 100644 --- a/test/daemon-read.test.ts +++ b/test/daemon-read.test.ts @@ -8,6 +8,7 @@ import { emptyPolicy } from "../src/core/policy/schema.js"; import type { ChainReadRelay } from "../src/daemon/chainRelay.js"; import type { ChainContext, KeyHandle } from "../src/core/types.js"; import type { ReadResponseJson } from "../src/daemon/protocol.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -65,7 +66,7 @@ describe("daemon read ops — whoami / query", () => { }; daemon = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-read-")), "signbox.sock") }, - { decode: decodeXprTransaction, signer: { sign: async () => ({ signature: "", transactionDigest: "" }) }, relay, now: () => NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer: { sign: async () => ({ signature: "", transactionDigest: "" }) }, relay, now: () => NOW }, ); daemon.registerAgent(runtime); }); @@ -117,7 +118,7 @@ describe("daemon read ops — whoami / query", () => { it("query without a relay configured fails closed", async () => { const noRelay = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-norelay-")), "signbox.sock") }, - { decode: decodeXprTransaction, signer: { sign: async () => ({ signature: "", transactionDigest: "" }) }, now: () => NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer: { sign: async () => ({ signature: "", transactionDigest: "" }) }, now: () => NOW }, ); noRelay.registerAgent({ agent: "funagent", permission: "active", chain: CHAIN, policy: emptyPolicy("XPR", CHAIN_ID), diff --git a/test/daemon-socket-mode.test.ts b/test/daemon-socket-mode.test.ts index eee7eff..3a2a34e 100644 --- a/test/daemon-socket-mode.test.ts +++ b/test/daemon-socket-mode.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const hoisted = vi.hoisted(() => ({ /** path → mode the file had when the daemon called chmodSync on it. */ @@ -50,6 +51,7 @@ function makeDaemon(cfg: { socketMode?: number; }): SignBoxDaemon { return new SignBoxDaemon(cfg, { + dialect: xprDialect, decode: () => { throw new Error("not used"); }, diff --git a/test/daemon.test.ts b/test/daemon.test.ts index edef3ae..3f877c7 100644 --- a/test/daemon.test.ts +++ b/test/daemon.test.ts @@ -14,6 +14,7 @@ import type { TransactionSigner, } from "../src/core/types.js"; import type { SignResponseJson } from "../src/daemon/protocol.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -50,7 +51,7 @@ function statelessPolicy() { limits: { maxPerTransaction: "1000.0000 XPR" }, }, ], - }); + }, xprDialect); } function statefulPolicy() { @@ -121,6 +122,7 @@ describe("SignBox daemon pipeline", () => { daemon = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "signbox.sock") }, { + dialect: xprDialect, decode: (input, context) => decodeXprTransaction(input, context), signer, now: () => nowMs, @@ -204,7 +206,7 @@ describe("SignBox daemon pipeline", () => { it("fails closed when the policy needs stateful quotas and no journal exists (§8.5)", async () => { const stateful = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "signbox.sock") }, - { decode: decodeXprTransaction, signer, now: () => nowMs }, + { dialect: xprDialect, decode: decodeXprTransaction, signer, now: () => nowMs }, ); stateful.registerAgent(agentRuntime({ policy: statefulPolicy() })); const response = await stateful.handleRequest(makeRequest()); @@ -253,7 +255,7 @@ describe("SignBox daemon over a real Unix socket", () => { socketPath = join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "signbox.sock"); daemon = new SignBoxDaemon( { socketPath }, - { decode: decodeXprTransaction, signer, now: () => BASE_NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer, now: () => BASE_NOW }, ); daemon.registerAgent({ agent: "superagent", @@ -314,7 +316,7 @@ describe("SignBox daemon over a real Unix socket", () => { it("refuses to start on an existing socket path", async () => { const second = new SignBoxDaemon( { socketPath }, - { decode: decodeXprTransaction, signer, now: () => BASE_NOW }, + { dialect: xprDialect, decode: decodeXprTransaction, signer, now: () => BASE_NOW }, ); await expect(second.start()).rejects.toThrow(); }); diff --git a/test/engine.test.ts b/test/engine.test.ts index 09c6a6f..4257371 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; import { evaluatePolicy, type EvaluationContext } from "../src/core/policy/engine.js"; import { emptyPolicy, validatePolicy, type Policy } from "../src/core/policy/schema.js"; import { decodeXprTransaction } from "../src/chains/xpr/decode.js"; @@ -7,6 +8,7 @@ import type { ChainContext, DecodedTransaction } from "../src/core/types.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "mainnet", chainId: CHAIN_ID }; const CTX: EvaluationContext = { + dialect: xprDialect, agent: "superagent", agentPermission: "xp2vr3", chainId: CHAIN_ID, @@ -49,7 +51,7 @@ const SPEC_POLICY: Policy = validatePolicy({ }, }, ], -}); +}, xprDialect); function transfer(overrides?: { to?: string; @@ -270,7 +272,7 @@ describe("policy engine — multi-action hardening", () => { limits: { maxPerTransaction: "1000.0000 XPR" }, }, ], - }); + }, xprDialect); const { decision } = evaluatePolicy(multiTransfer(16, "1000.0000 XPR"), policy, CTX); expect(decision).toMatchObject({ effect: "deny", code: "LIMIT_EXCEEDED" }); }); @@ -289,7 +291,7 @@ describe("policy engine — multi-action hardening", () => { limits: { maxPerTransaction: "100.0000 XPR" }, }, ], - }); + }, xprDialect); // 2 x 40 = 80 <= 100: allowed. expect(evaluatePolicy(multiTransfer(2, "40.0000 XPR"), policy, CTX).decision.effect).toBe("allow"); // 3 x 40 = 120 > 100: refused. @@ -312,7 +314,7 @@ describe("policy engine — multi-action hardening", () => { match: { contract: "eosio.token", action: "transfer", "data.from": "$agent" }, }, ], - }); + }, xprDialect); expect(evaluatePolicy(multiTransfer(3, "1.0000 XPR"), policy, CTX).decision.effect).toBe("allow"); }); @@ -330,7 +332,7 @@ describe("policy engine — multi-action hardening", () => { limits: { maxCountPerRecipientPerHour: 3 }, }, ], - }); + }, xprDialect); const { quotaDemands } = evaluatePolicy(multiTransfer(3, "1.0000 XPR"), policy, CTX); expect(quotaDemands).toHaveLength(3); expect(quotaDemands.every((d) => d.maxCountPerRecipientPerHour === 3 && d.recipient === "alice")).toBe( @@ -341,11 +343,11 @@ describe("policy engine — multi-action hardening", () => { describe("policy schema validation (§7.5 — the daemon is the sole validator)", () => { it("rejects unknown top-level fields", () => { - expect(() => validatePolicy({ ...SPEC_POLICY, extra: true })).toThrow(); + expect(() => validatePolicy({ ...SPEC_POLICY, extra: true }, xprDialect)).toThrow(); }); it("rejects default allow", () => { - expect(() => validatePolicy({ ...SPEC_POLICY, default: "allow" })).toThrow(); + expect(() => validatePolicy({ ...SPEC_POLICY, default: "allow" }, xprDialect)).toThrow(); }); it("rejects unknown match paths", () => { @@ -353,7 +355,7 @@ describe("policy schema validation (§7.5 — the daemon is the sole validator)" validatePolicy({ ...SPEC_POLICY, rules: [{ id: "x", effect: "allow", match: { "shell.exec": "rm" } }], - }), + }, xprDialect), ).toThrow(); }); @@ -365,7 +367,7 @@ describe("policy schema validation (§7.5 — the daemon is the sole validator)" { id: "dup", effect: "allow", match: { contract: "a" } }, { id: "dup", effect: "deny", match: { contract: "b" } }, ], - }), + }, xprDialect), ).toThrow(); }); @@ -381,7 +383,7 @@ describe("policy schema validation (§7.5 — the daemon is the sole validator)" limits: { maxPerTransaction: "1.0000 XPR" }, }, ], - }), + }, xprDialect), ).toThrow(); }); @@ -397,7 +399,7 @@ describe("policy schema validation (§7.5 — the daemon is the sole validator)" limits: { maxPerTransaction: "1.0 lol" }, }, ], - }), + }, xprDialect), ).toThrow(); }); }); diff --git a/test/policyCache.test.ts b/test/policyCache.test.ts index 8dcf92a..f718392 100644 --- a/test/policyCache.test.ts +++ b/test/policyCache.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { PolicyCache } from "../src/daemon/policyCache.js"; import { canonicalize } from "../src/core/canonical/jcs.js"; import type { PolicyReader, PolicyRowRaw } from "../src/daemon/chainPolicyReader.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const NOW = Date.parse("2026-07-30T12:00:00.000Z"); @@ -65,7 +66,7 @@ function dbPath(): string { describe("PolicyCache — validation", () => { it("fetches, validates and returns a policy", async () => { const reader = new FakeReader(rowFor("superagent", 5)); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); const result = await cache.get("superagent", NOW); expect("unavailable" in result).toBe(false); if (!("unavailable" in result)) { @@ -123,7 +124,7 @@ describe("PolicyCache — validation", () => { it("fails closed when the RPC is unreachable (strict default)", async () => { const reader = new FakeReader(rowFor("superagent", 1)); reader.throwNext = true; - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); expect(await cache.get("superagent", NOW)).toEqual({ unavailable: "POLICY_UNAVAILABLE" }); cache.close(); }); @@ -132,7 +133,7 @@ describe("PolicyCache — validation", () => { describe("PolicyCache — anti-rollback (§14.5)", () => { it("refuses a version below the highest ever seen", async () => { const reader = new FakeReader(rowFor("superagent", 7)); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); expect("unavailable" in (await cache.get("superagent", NOW))).toBe(false); // A lying RPC now serves an older, more permissive version. @@ -145,13 +146,13 @@ describe("PolicyCache — anti-rollback (§14.5)", () => { it("persists the watermark across a restart (same db file)", async () => { const path = dbPath(); const reader = new FakeReader(rowFor("superagent", 9)); - const first = new PolicyCache(path, reader, {}, () => NOW); + const first = new PolicyCache(path, reader, xprDialect, {}, () => NOW); await first.get("superagent", NOW); first.close(); // New cache instance, same file; RPC tries to downgrade. const reader2 = new FakeReader(rowFor("superagent", 8)); - const second = new PolicyCache(path, reader2, {}, () => NOW + 60_000); + const second = new PolicyCache(path, reader2, xprDialect, {}, () => NOW + 60_000); expect(await second.get("superagent", NOW + 60_000)).toEqual({ unavailable: "POLICY_UNAVAILABLE", }); @@ -160,7 +161,7 @@ describe("PolicyCache — anti-rollback (§14.5)", () => { it("accepts an equal or higher version", async () => { const reader = new FakeReader(rowFor("superagent", 3)); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); await cache.get("superagent", NOW); reader.row = rowFor("superagent", 4); const next = await cache.get("superagent", NOW + 60_000); @@ -173,7 +174,7 @@ describe("PolicyCache — anti-rollback (§14.5)", () => { describe("PolicyCache — freshness (30s / 10s)", () => { it("does not re-fetch a fresh non-financial policy within 30s", async () => { const reader = new FakeReader(rowFor("superagent", 1, { financial: false })); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); await cache.get("superagent", NOW); await cache.get("superagent", NOW + 20_000); // < 30s expect(reader.reads).toBe(1); @@ -182,7 +183,7 @@ describe("PolicyCache — freshness (30s / 10s)", () => { it("re-fetches a non-financial policy after 30s", async () => { const reader = new FakeReader(rowFor("superagent", 1, { financial: false })); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); await cache.get("superagent", NOW); await cache.get("superagent", NOW + 31_000); expect(reader.reads).toBe(2); @@ -191,7 +192,7 @@ describe("PolicyCache — freshness (30s / 10s)", () => { it("re-confirms a financial policy after 10s (tighter freshness)", async () => { const reader = new FakeReader(rowFor("superagent", 1, { financial: true })); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); await cache.get("superagent", NOW); await cache.get("superagent", NOW + 9_000); // < 10s: no refetch expect(reader.reads).toBe(1); @@ -203,9 +204,7 @@ describe("PolicyCache — freshness (30s / 10s)", () => { it("controlled grace serves the last-good policy when strict is off", async () => { const reader = new FakeReader(rowFor("superagent", 1)); const cache = new PolicyCache( - ":memory:", - reader, - { strict: false, graceMs: 60_000 }, + ":memory:", reader, xprDialect, { strict: false, graceMs: 60_000 }, () => NOW, ); await cache.get("superagent", NOW); @@ -219,7 +218,7 @@ describe("PolicyCache — freshness (30s / 10s)", () => { describe("PolicyCache — enabled flag", () => { it("propagates the on-chain enabled flag", async () => { const reader = new FakeReader(rowFor("superagent", 1, { enabled: false })); - const cache = new PolicyCache(":memory:", reader, {}, () => NOW); + const cache = new PolicyCache(":memory:", reader, xprDialect, {}, () => NOW); const result = await cache.get("superagent", NOW); expect("unavailable" in result).toBe(false); if (!("unavailable" in result)) expect(result.enabled).toBe(false); diff --git a/test/provider.test.ts b/test/provider.test.ts index a239cf4..645487d 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -10,10 +10,11 @@ import { decodeXprTransaction } from "../src/chains/xpr/decode.js"; import { resolveProviders } from "../src/daemon/providerResolver.js"; import type { ChainReadRelay } from "../src/daemon/chainRelay.js"; import type { ChainContext, DecodedTransaction } from "../src/core/types.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; -const CTX: EvaluationContext = { agent: "funagent", agentPermission: "active", chainId: CHAIN_ID, policyVersion: 1 }; +const CTX: EvaluationContext = { agent: "funagent", agentPermission: "active", chainId: CHAIN_ID, policyVersion: 1, dialect: xprDialect }; /** Allow a transfer only if the recipient is in an on-chain whitelist row. */ const WHITELIST_POLICY: Policy = validatePolicy({ @@ -36,7 +37,7 @@ const WHITELIST_POLICY: Policy = validatePolicy({ ], }, ], -}); +}, xprDialect); function transfer(to: string): DecodedTransaction { return decodeXprTransaction( @@ -128,7 +129,7 @@ describe("policy providers — xpr.rpc.tableRow (§8.4)", () => { ], }, ], - }); + }, xprDialect); const tx = transfer("alice"); const queries = collectProviderQueries(tx, policy, CTX); const goldEvidence = { [queries[0]!.key]: { ok: true, found: true, row: { tier: "gold" } } } as ProviderEvidenceMap; @@ -143,13 +144,13 @@ describe("provider resolver — normalization + fail closed", () => { it("normalizes a found row", async () => { const relay: ChainReadRelay = { call: async () => ({ rows: [{ allowed: ["alice"] }], more: false }) }; - const evidence = await resolveProviders([query], relay); + const evidence = await resolveProviders([query], relay, xprDialect); expect(evidence[query.key]).toMatchObject({ ok: true, found: true, row: { allowed: ["alice"] } }); }); it("normalizes an empty result to a deterministic not-found", async () => { const relay: ChainReadRelay = { call: async () => ({ rows: [], more: false }) }; - const evidence = await resolveProviders([query], relay); + const evidence = await resolveProviders([query], relay, xprDialect); expect(evidence[query.key]).toMatchObject({ ok: true, found: false, row: null }); }); @@ -159,12 +160,12 @@ describe("provider resolver — normalization + fail closed", () => { throw new Error("unreachable"); }, }; - const evidence = await resolveProviders([query], relay); + const evidence = await resolveProviders([query], relay, xprDialect); expect(evidence[query.key]).toEqual({ ok: false }); }); it("fails closed when no relay is available", async () => { - const evidence = await resolveProviders([query], undefined); + const evidence = await resolveProviders([query], undefined, xprDialect); expect(evidence[query.key]).toEqual({ ok: false }); }); }); diff --git a/test/quotas.test.ts b/test/quotas.test.ts index 3c40b1c..3215ff6 100644 --- a/test/quotas.test.ts +++ b/test/quotas.test.ts @@ -15,6 +15,7 @@ import type { SignedTransactionResult, TransactionSigner, } from "../src/core/types.js"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; const CHAIN_ID = "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; const CHAIN: ChainContext = { chain: "XPR", network: "testnet", chainId: CHAIN_ID }; @@ -242,7 +243,7 @@ describe("daemon pipeline with quota journal", () => { }, }, ], - }); + }, xprDialect); } function makeRequest(n: number, to = "alice"): string { @@ -277,6 +278,7 @@ describe("daemon pipeline with quota journal", () => { daemon = new SignBoxDaemon( { socketPath: join(mkdtempSync(join(tmpdir(), "signbox-daemon-")), "signbox.sock") }, { + dialect: xprDialect, decode: decodeXprTransaction, signer, quotas: new QuotaJournal(":memory:"), diff --git a/test/vocabulary.test.ts b/test/vocabulary.test.ts index fe5f8f7..d97aa8b 100644 --- a/test/vocabulary.test.ts +++ b/test/vocabulary.test.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it } from "vitest"; +import { xprDialect } from "../src/chains/xpr/dialect.js"; import { MATCH_PATH_RE, RULE_ID_RE, SELECT_FIELD_RE } from "../src/core/policy/vocabulary.js"; import { validatePolicy } from "../src/core/policy/schema.js"; @@ -25,14 +26,14 @@ describe("policy vocabulary — regex ↔ validator parity", () => { it("accepts exactly what the validator accepts (match paths)", () => { for (const key of ACCEPTED) { expect(MATCH_PATH_RE.test(key), key).toBe(true); - expect(() => validatePolicy(policyWithMatchKey(key)), key).not.toThrow(); + expect(() => validatePolicy(policyWithMatchKey(key), xprDialect), key).not.toThrow(); } }); it("rejects exactly what the validator rejects (match paths)", () => { for (const key of REJECTED) { expect(MATCH_PATH_RE.test(key), key).toBe(false); - expect(() => validatePolicy(policyWithMatchKey(key)), key).toThrow(); + expect(() => validatePolicy(policyWithMatchKey(key), xprDialect), key).toThrow(); } }); diff --git a/vitest.config.ts b/vitest.config.ts index 2fb7ad9..ae516d5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,5 +10,8 @@ export default defineConfig({ test: { include: ["test/**/*.test.ts"], exclude: ["**/node_modules/**", "dist/**", "contract/**"], + // The keystore suites derive Argon2id MODERATE keys (256 MiB, ~1-2s each) + // by design; under full-suite parallelism they overrun the 5s default. + testTimeout: 60_000, }, });