@crediolabs/policy-synth 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +193 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  3. package/dist/adapters/interpreter/adapter.js +522 -0
  4. package/dist/adapters/interpreter/index.d.ts +1 -0
  5. package/dist/adapters/interpreter/index.js +2 -0
  6. package/dist/adapters/oz/adapter.js +20 -18
  7. package/dist/codegen/compile-gate.d.ts +33 -0
  8. package/dist/codegen/compile-gate.js +119 -0
  9. package/dist/codegen/index.d.ts +2 -0
  10. package/dist/codegen/index.js +8 -0
  11. package/dist/codegen/template.d.ts +18 -0
  12. package/dist/codegen/template.js +131 -0
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/ir/types.d.ts +13 -2
  17. package/dist/mandate/to-ir.js +4 -4
  18. package/dist/mandate/types.d.ts +1 -1
  19. package/dist/predicate/encode.d.ts +10 -0
  20. package/dist/predicate/encode.js +249 -0
  21. package/dist/predicate/index.d.ts +1 -0
  22. package/dist/predicate/index.js +2 -0
  23. package/dist/review-card/builder.d.ts +14 -0
  24. package/dist/review-card/builder.js +261 -0
  25. package/dist/review-card/conflict.d.ts +40 -0
  26. package/dist/review-card/conflict.js +111 -0
  27. package/dist/review-card/cross-check.d.ts +11 -0
  28. package/dist/review-card/cross-check.js +148 -0
  29. package/dist/review-card/index.d.ts +3 -0
  30. package/dist/review-card/index.js +4 -0
  31. package/dist/seams/types.d.ts +1 -1
  32. package/dist/synth/compose-from-recording.d.ts +35 -7
  33. package/dist/synth/compose-from-recording.js +231 -40
  34. package/dist/synth/deny-cases.d.ts +12 -0
  35. package/dist/synth/deny-cases.js +361 -0
  36. package/dist/synth/evaluate.d.ts +39 -0
  37. package/dist/synth/evaluate.js +425 -0
  38. package/dist/synth/harness.d.ts +16 -0
  39. package/dist/synth/harness.js +26 -0
  40. package/dist/synth/index.d.ts +4 -0
  41. package/dist/synth/index.js +4 -0
  42. package/dist/synth/lower.d.ts +2 -2
  43. package/dist/synth/minimize.d.ts +4 -0
  44. package/dist/synth/minimize.js +38 -0
  45. package/dist/synth/predicate-literals.d.ts +5 -0
  46. package/dist/synth/predicate-literals.js +25 -0
  47. package/dist/synth/synthesize-from-mandate.js +5 -5
  48. package/dist/synth/synthesize-from-recording.d.ts +32 -3
  49. package/dist/synth/synthesize-from-recording.js +491 -17
  50. package/dist/types.d.ts +23 -1
  51. package/dist/verify/envelope.d.ts +15 -0
  52. package/dist/verify/envelope.js +22 -0
  53. package/dist/verify/index.d.ts +3 -0
  54. package/dist/verify/index.js +3 -0
  55. package/dist/verify/simulate.d.ts +31 -0
  56. package/dist/verify/simulate.js +242 -0
  57. package/dist/verify/verify.d.ts +21 -0
  58. package/dist/verify/verify.js +173 -0
  59. package/package.json +1 -1
  60. package/src/adapters/interpreter/adapter.ts +642 -0
  61. package/src/adapters/interpreter/index.ts +8 -0
  62. package/src/adapters/oz/adapter.ts +20 -18
  63. package/src/codegen/compile-gate.ts +162 -0
  64. package/src/codegen/index.ts +17 -0
  65. package/src/codegen/template.ts +148 -0
  66. package/src/errors.ts +2 -0
  67. package/src/index.ts +2 -0
  68. package/src/ir/types.ts +9 -2
  69. package/src/mandate/to-ir.ts +4 -4
  70. package/src/mandate/types.ts +1 -1
  71. package/src/predicate/encode.ts +307 -0
  72. package/src/predicate/index.ts +3 -0
  73. package/src/review-card/builder.ts +303 -0
  74. package/src/review-card/conflict.ts +143 -0
  75. package/src/review-card/cross-check.ts +158 -0
  76. package/src/review-card/index.ts +12 -0
  77. package/src/seams/types.ts +1 -1
  78. package/src/synth/compose-from-recording.ts +283 -49
  79. package/src/synth/deny-cases.ts +447 -0
  80. package/src/synth/evaluate.ts +493 -0
  81. package/src/synth/harness.ts +40 -0
  82. package/src/synth/index.ts +12 -0
  83. package/src/synth/lower.ts +2 -2
  84. package/src/synth/minimize.ts +44 -0
  85. package/src/synth/predicate-literals.ts +27 -0
  86. package/src/synth/synthesize-from-mandate.ts +5 -5
  87. package/src/synth/synthesize-from-recording.ts +575 -18
  88. package/src/types.ts +19 -2
  89. package/src/verify/envelope.ts +28 -0
  90. package/src/verify/index.ts +5 -0
  91. package/src/verify/simulate.ts +292 -0
  92. package/src/verify/verify.ts +224 -0
@@ -1,27 +1,47 @@
1
1
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
2
2
  //
3
3
  // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
4
- // bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
5
- // `createOzAdapter` pair used by the deterministic Mandate path. The flow:
4
+ // bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
5
+ // pair used by the deterministic Mandate path. The flow:
6
6
  //
7
7
  // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
8
8
  // the caller may relax via `confidenceOverride.threshold`).
9
9
  // 2. `lower(tx)` -> IntentFacts.
10
10
  // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
11
- // 4. `composeFromRecording(facts, scope, opts)` -> PolicyIR + ambiguities.
11
+ // 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
12
+ // interpreterIr (predicate-shape), ambiguities, warnings }.
12
13
  // 5. `ozAdapter.compile(ir)` -> CompileResult.
13
- // 6. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
14
- // (the OZ `uncovered` Path-B markers, prefixed), and ambiguities
15
- // (DURATION_UNSPECIFIED etc.).
14
+ // 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
15
+ // opted in to the interpreter adapter:
16
+ // a. Run `interpreterAdapter.compile(interpreterIr)`.
17
+ // b. Throw -> SYNTHESIS_ERROR carrying the gate code
18
+ // (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
19
+ // ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
20
+ // policy - a warning can be ignored and yields an over-broad rule.
21
+ // c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
22
+ // descriptor (the user asked for the interpreter to enforce these
23
+ // constraints; refusing them silently is the audit failure mode).
24
+ // d. `covered === true` -> merge its PolicyDocument + PolicyRef into
25
+ // the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
26
+ // is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
27
+ // `OZ_LIMITS.maxPoliciesPerRule` (= 5).
28
+ // 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
29
+ // (the OZ `uncovered` markers, prefixed), and ambiguities.
16
30
  //
17
31
  // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
18
32
  // No randomness, no clock, no globals.
33
+ import { createInterpreterAdapter, lowerRuleToPredicate, PLACEHOLDER_INTERPRETER_ADDRESS, } from "../adapters/interpreter/adapter.js";
19
34
  import { createOzAdapter } from "../adapters/oz/adapter.js";
20
- import { SOROBAN_LIMITS, } from "../types.js";
35
+ import { encodePredicate } from "../predicate/encode.js";
36
+ import { OZ_LIMITS, SOROBAN_LIMITS, } from "../types.js";
21
37
  import { composeFromRecording, } from "./compose-from-recording.js";
38
+ import { generateCases } from "./deny-cases.js";
39
+ import { evaluate } from "./evaluate.js";
40
+ import { runHarness } from "./harness.js";
22
41
  import { lower } from "./lower.js";
42
+ import { minimize } from "./minimize.js";
23
43
  import { decideScope } from "./scope.js";
24
- const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): ';
44
+ const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
25
45
  /** Synthesize a ProposedPolicy from a recorded transaction. */
26
46
  export function synthesizeFromRecording(tx, opts, ozConfig) {
27
47
  // 0. validate inputs (fail closed - never synthesize from garbage).
@@ -68,7 +88,7 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
68
88
  return scopeRes;
69
89
  const scope = scopeRes.data;
70
90
  if (scope.kind !== 'call_contract') {
71
- // A default scope on the recording-path is not currently a Path-A flow;
91
+ // A default scope on the recording-path is not currently a covered flow;
72
92
  // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
73
93
  // over-broad default-scoped policy.
74
94
  return {
@@ -85,12 +105,13 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
85
105
  const topLevel = tx.invocations[0] ?? null;
86
106
  const composeOpts = {
87
107
  network: opts.network,
108
+ interpreterEnabled: opts.interpreter !== undefined,
88
109
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
89
110
  };
90
111
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
91
- // 5. OZ compile.
92
- const adapter = createOzAdapter(ozConfig);
93
- const compileRes = adapter.compile(composed.ir);
112
+ // 5. OZ compile (always runs).
113
+ const ozAdapter = createOzAdapter(ozConfig);
114
+ const compileRes = ozAdapter.compile(composed.ir);
94
115
  if (!compileRes.proposed) {
95
116
  return {
96
117
  ok: false,
@@ -103,21 +124,312 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
103
124
  },
104
125
  };
105
126
  }
106
- // 6. assemble ProposedPolicy.
127
+ // 6. Interpreter compile (opt-in; fail-closed when the user routed real
128
+ // restrictions to the interpreter).
129
+ //
130
+ // When the caller opts in, the interpreter always runs - even for a
131
+ // scope-only interpreter IR (no routed constraints) - because the
132
+ // interpreter adapter lowers `scope.contract` + `scope.method` into a
133
+ // `call_contract == ...` and `call_fn == ...` predicate sibling pair.
134
+ // This is the real per-method enforcement the OZ adapter cannot do.
135
+ //
136
+ // When the caller does NOT opt in: today's behaviour is preserved. The
137
+ // compose step routes every constraint to the OZ IR; OZ flags the ones
138
+ // it cannot lower as `uncovered` and the orchestrator surfaces them as
139
+ // warnings. No interpreter doc is produced.
140
+ const interpreterOpts = opts.interpreter;
141
+ let interpreterPolicyDocument = null;
142
+ let interpreterPolicyRef = null;
143
+ if (interpreterOpts) {
144
+ const interpreterConfig = {
145
+ network: opts.network,
146
+ installNonce: interpreterOpts.installNonce ?? 1,
147
+ smartAccountAddress: interpreterOpts.smartAccountAddress,
148
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
149
+ };
150
+ // The starting PredicateNode the self-verify + minimise pipeline drives.
151
+ // Two paths populate it:
152
+ // - Test seam (`__testPredicateNode` set): use it directly, skipping the
153
+ // adapter's compile(). Production code MUST NOT set the seam; it
154
+ // exists so the pipeline can be exercised on hand-crafted predicates
155
+ // (redundant conjuncts for minimise, deliberately over-broad leaves
156
+ // for DENY_CASE_FAILURE).
157
+ // - Normal: call the interpreter adapter's compile() so the existing
158
+ // enforcement gates (SCOPE_SELF_CALL / ORACLE_LEAF_INVALID_POSITION /
159
+ // ORACLE_PARAMS_OUT_OF_RANGE + uncovered reporting) fire first, then
160
+ // re-derive the pre-encoding PredicateNode via `lowerRuleToPredicate`
161
+ // so minimise + harness see the same shape the encoder will see.
162
+ let startingPredicate = null;
163
+ const testSeam = interpreterOpts.__testPredicateNode;
164
+ if (testSeam !== undefined) {
165
+ startingPredicate = testSeam;
166
+ }
167
+ else {
168
+ const interpreterAdapter = createInterpreterAdapter(interpreterConfig);
169
+ let interpreterRes;
170
+ try {
171
+ interpreterRes = interpreterAdapter.compile(composed.interpreterIr);
172
+ }
173
+ catch (e) {
174
+ // Interpreter enforcement-gate throws (SCOPE_SELF_CALL,
175
+ // ORACLE_LEAF_INVALID_POSITION, ORACLE_PARAMS_OUT_OF_RANGE). Surface
176
+ // the gate code so the caller can act; never install an OZ-only
177
+ // partial policy - the user explicitly asked for these constraints
178
+ // to be enforced and silently dropping them yields an over-broad rule.
179
+ const code = e.code;
180
+ const allowedCodes = [
181
+ 'SCOPE_SELF_CALL',
182
+ 'ORACLE_LEAF_INVALID_POSITION',
183
+ 'ORACLE_PARAMS_OUT_OF_RANGE',
184
+ 'MALFORMED_PREDICATE',
185
+ 'PREDICATE_TOO_LARGE',
186
+ 'PREDICATE_TOO_DEEP',
187
+ 'TOO_MANY_LEAVES',
188
+ 'IN_OPERAND_LIMIT',
189
+ 'PREDICATE_ORACLE_OVER_LIMIT',
190
+ ];
191
+ const surfacedCode = code && allowedCodes.includes(code) ? code : 'SYNTHESIS_ERROR';
192
+ return {
193
+ ok: false,
194
+ error: {
195
+ code: surfacedCode,
196
+ message: `interpreter predicate could not be compiled: ${e.message}`,
197
+ severity: 'error',
198
+ retryable: false,
199
+ },
200
+ };
201
+ }
202
+ if (!interpreterRes.covered) {
203
+ return {
204
+ ok: false,
205
+ error: {
206
+ code: 'SYNTHESIS_ERROR',
207
+ message: `interpreter predicate is not fully covered: ${interpreterRes.uncovered.join('; ')}`,
208
+ severity: 'error',
209
+ retryable: false,
210
+ details: { uncovered: interpreterRes.uncovered },
211
+ },
212
+ };
213
+ }
214
+ if (!interpreterRes.proposed) {
215
+ return {
216
+ ok: false,
217
+ error: {
218
+ code: 'SYNTHESIS_ERROR',
219
+ message: 'interpreter adapter returned no installable policy',
220
+ severity: 'error',
221
+ retryable: false,
222
+ },
223
+ };
224
+ }
225
+ interpreterPolicyDocument = interpreterRes.proposed.policyDocuments[0] ?? null;
226
+ interpreterPolicyRef = interpreterRes.proposed.policyRefs[0] ?? null;
227
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
228
+ return {
229
+ ok: false,
230
+ error: {
231
+ code: 'SYNTHESIS_ERROR',
232
+ message: 'interpreter adapter returned a policy missing the document or ref',
233
+ severity: 'error',
234
+ retryable: false,
235
+ },
236
+ };
237
+ }
238
+ const firstInterpreterRule = composed.interpreterIr.rules[0];
239
+ if (!firstInterpreterRule) {
240
+ return {
241
+ ok: false,
242
+ error: {
243
+ code: 'SYNTHESIS_ERROR',
244
+ message: 'interpreter IR has no rules to lower',
245
+ severity: 'error',
246
+ retryable: false,
247
+ },
248
+ };
249
+ }
250
+ startingPredicate = lowerRuleToPredicate(firstInterpreterRule, interpreterConfig);
251
+ }
252
+ if (!startingPredicate) {
253
+ return {
254
+ ok: false,
255
+ error: {
256
+ code: 'SYNTHESIS_ERROR',
257
+ message: 'interpreter predicate was not derived',
258
+ severity: 'error',
259
+ retryable: false,
260
+ },
261
+ };
262
+ }
263
+ // 6b. Self-verify + minimise. We always build the same permit EvalContext
264
+ // from the recorded tx + synthesised constraints so the harness
265
+ // reasons about the intended call (the only one the user actually
266
+ // recorded). The recorded call MUST permit under the predicate; the
267
+ // generated deny battery MUST all deny.
268
+ if (!topLevel) {
269
+ // The orchestrator guarantees this for the call_contract branch above;
270
+ // a default-scoped policy never reaches the interpreter block. Fail
271
+ // closed rather than attempt to self-verify a vague shape.
272
+ return {
273
+ ok: false,
274
+ error: {
275
+ code: 'SYNTHESIS_ERROR',
276
+ message: 'self-verify requires a recorded top-level invocation',
277
+ severity: 'error',
278
+ retryable: false,
279
+ },
280
+ };
281
+ }
282
+ if (scope.kind !== 'call_contract') {
283
+ return {
284
+ ok: false,
285
+ error: {
286
+ code: 'SYNTHESIS_ERROR',
287
+ message: 'self-verify requires a call_contract scope',
288
+ severity: 'error',
289
+ retryable: false,
290
+ },
291
+ };
292
+ }
293
+ const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
294
+ // Top-level `and` predicates are what generateCases + minimize assume;
295
+ // for any other shape we skip minimise (encode as-is) but still assert
296
+ // the permit case permits. An over-broad non-and predicate is still
297
+ // caught by the harness on the next step.
298
+ const finalPredicate = startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate;
299
+ const harnessCases = generateCases(finalPredicate, permitCtx);
300
+ const harnessResult = runHarness(finalPredicate, harnessCases);
301
+ if (!harnessResult.ok) {
302
+ return {
303
+ ok: false,
304
+ error: {
305
+ code: 'DENY_CASE_FAILURE',
306
+ message: `self-verify harness failed for the interpreter predicate (${harnessResult.failures.length} failure(s))`,
307
+ severity: 'error',
308
+ retryable: false,
309
+ details: { failures: harnessResult.failures },
310
+ },
311
+ };
312
+ }
313
+ const evalResult = evaluate(finalPredicate, permitCtx);
314
+ if (!evalResult.permit) {
315
+ return {
316
+ ok: false,
317
+ error: {
318
+ code: 'DENY_CASE_FAILURE',
319
+ message: `intended recorded call was denied by the interpreter predicate: ${evalResult.reason}`,
320
+ severity: 'error',
321
+ retryable: false,
322
+ details: { reason: evalResult.reason },
323
+ },
324
+ };
325
+ }
326
+ // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
327
+ // canonical bytes back onto the PolicyDocument + PolicyRef. minimize
328
+ // and encodePredicate are pure and deterministic, so the re-encoded
329
+ // doc is byte-identical to what compile() would have produced for
330
+ // the minimised tree.
331
+ const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
332
+ if (testSeam !== undefined) {
333
+ // Test seam: build the PolicyDocument + PolicyRef now (compile() was
334
+ // skipped). Shape mirrors the adapter's output exactly.
335
+ interpreterPolicyDocument = {
336
+ grammarVersion: 1,
337
+ installNonce: interpreterOpts.installNonce ?? 1,
338
+ encodedPredicate,
339
+ predicateHash,
340
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
341
+ };
342
+ interpreterPolicyRef = {
343
+ kind: 'interpreter',
344
+ interpreterAddress: PLACEHOLDER_INTERPRETER_ADDRESS,
345
+ predicateBlobBase64: encodedPredicate,
346
+ };
347
+ }
348
+ else {
349
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
350
+ return {
351
+ ok: false,
352
+ error: {
353
+ code: 'SYNTHESIS_ERROR',
354
+ message: 'interpreter policy document or ref was lost before re-encode',
355
+ severity: 'error',
356
+ retryable: false,
357
+ },
358
+ };
359
+ }
360
+ interpreterPolicyDocument = {
361
+ ...interpreterPolicyDocument,
362
+ encodedPredicate,
363
+ predicateHash,
364
+ };
365
+ if (interpreterPolicyRef.kind === 'interpreter') {
366
+ interpreterPolicyRef = {
367
+ ...interpreterPolicyRef,
368
+ predicateBlobBase64: encodedPredicate,
369
+ };
370
+ }
371
+ }
372
+ }
373
+ // When the interpreter succeeds, OZ-side `uncovered` warnings that the
374
+ // interpreter actually lowered (per-method scoping, recipient allowlists,
375
+ // eq_seq paths, oracle_price / invocation_count / token-mismatch
376
+ // window_spent) are misleading: OZ really did not lower them, but the
377
+ // interpreter did. Drop those so the user-facing warnings reflect what is
378
+ // still UN-enforced, not what OZ alone could not do.
379
+ const ozUncovered = interpreterPolicyRef
380
+ ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
381
+ : compileRes.uncovered;
382
+ // 7. Merge into the OZ-shaped ProposedPolicy. Order:
383
+ // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
384
+ const ozRefs = compileRes.proposed.policyRefs;
385
+ const mergedRefs = [];
386
+ if (interpreterPolicyRef)
387
+ mergedRefs.push(interpreterPolicyRef);
388
+ for (const r of ozRefs)
389
+ mergedRefs.push(r);
390
+ if (mergedRefs.length > OZ_LIMITS.maxPoliciesPerRule) {
391
+ return {
392
+ ok: false,
393
+ error: {
394
+ code: 'POLICY_CAP_EXCEEDED',
395
+ message: `merged policy count ${mergedRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`,
396
+ severity: 'error',
397
+ retryable: false,
398
+ },
399
+ };
400
+ }
401
+ const mergedContextRule = {
402
+ ...compileRes.proposed.contextRule,
403
+ policies: mergedRefs,
404
+ };
107
405
  const proposed = {
108
- ...compileRes.proposed,
406
+ contextRule: mergedContextRule,
407
+ policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
408
+ policyRefs: mergedRefs,
109
409
  parseConfidence: { ...tx.parseConfidence },
110
410
  warnings: [
111
- ...compileRes.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
112
- ...composed.warnings.map((w) => `${PATH_B_PREFIX}${w}`),
411
+ ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
412
+ ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
113
413
  ],
114
414
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
115
415
  };
116
416
  return { ok: true, data: proposed };
117
417
  }
418
+ /** OZ-side `uncovered` warning patterns that the interpreter adapter
419
+ * actually lowers when wired in. When the interpreter adapter succeeds, we
420
+ * drop matching entries from the OZ uncovered list so the user-facing
421
+ * warnings reflect what is still UN-enforced rather than what OZ alone
422
+ * could not do. Match the exact descriptor strings the OZ adapter emits
423
+ * (see `src/adapters/oz/adapter.ts#describeCondition` /
424
+ * `describeSelector`). */
425
+ const INTERPRETER_COVERED_OZ_PATTERN = /^per-method scoping to|^value allowlist on arg|^exact ordered sequence on arg|^oracle price condition on|^invocation-count window|^spending_limit on token .+ needs a CallContract context scoped to that token/;
118
426
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
119
427
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
120
- * positive i128 decimal string; network mainnet|testnet. */
428
+ * positive i128 decimal string; network mainnet|testnet. When the caller
429
+ * opts into the interpreter adapter, `smartAccountAddress` must be a C...
430
+ * contract address (the on-chain policy-bound account, NOT the G... source
431
+ * account from the recording), `installNonce` must be a positive integer
432
+ * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
121
433
  function validateOptions(opts) {
122
434
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
123
435
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
@@ -144,6 +456,32 @@ function validateOptions(opts) {
144
456
  return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
145
457
  }
146
458
  }
459
+ if (opts.interpreter) {
460
+ const sa = opts.interpreter.smartAccountAddress;
461
+ if (typeof sa !== 'string' || sa.length === 0) {
462
+ return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
463
+ }
464
+ if (!isContractAddress(sa)) {
465
+ return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
466
+ }
467
+ const nonce = opts.interpreter.installNonce;
468
+ if (nonce !== undefined && !isPositiveInt(nonce)) {
469
+ return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`);
470
+ }
471
+ const op = opts.interpreter.oracleParams;
472
+ if (op) {
473
+ const MAX_STALE = 600;
474
+ const MAX_DEV = 200;
475
+ if (op.maxStalenessSeconds !== undefined &&
476
+ (!isPositiveInt(op.maxStalenessSeconds) || op.maxStalenessSeconds > MAX_STALE)) {
477
+ return synthesisError(`interpreter.oracleParams.maxStalenessSeconds must be a positive integer <= ${MAX_STALE} (tighten-only), got: ${op.maxStalenessSeconds}`);
478
+ }
479
+ if (op.maxDeviationBps !== undefined &&
480
+ (!isPositiveInt(op.maxDeviationBps) || op.maxDeviationBps > MAX_DEV)) {
481
+ return synthesisError(`interpreter.oracleParams.maxDeviationBps must be a positive integer <= ${MAX_DEV} (tighten-only), got: ${op.maxDeviationBps}`);
482
+ }
483
+ }
484
+ }
147
485
  return null;
148
486
  }
149
487
  function isPositiveInt(n) {
@@ -160,6 +498,12 @@ function isPositiveI128(s) {
160
498
  return false;
161
499
  }
162
500
  }
501
+ /** True when `s` looks like a Stellar C... contract address (strkey). The
502
+ * source account from the recording is a G... account; the smart account is
503
+ * a separate C... contract. */
504
+ function isContractAddress(s) {
505
+ return s.startsWith('C') && s.length === 56;
506
+ }
163
507
  function synthesisError(message) {
164
508
  return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false };
165
509
  }
@@ -176,3 +520,133 @@ function mergeAmbiguities(...lists) {
176
520
  }
177
521
  return out;
178
522
  }
523
+ /** Build the permit `EvalContext` the self-verify harness drives. The shape
524
+ * mirrors the intended recorded call (the only call the user actually
525
+ * performed) so:
526
+ * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
527
+ * permits the recorded call it was derived from); a failure surfaces
528
+ * as DENY_CASE_FAILURE.
529
+ * - `generateCases(predicate, ctx)` produces a deny battery that reflects
530
+ * the actual recorded move (real amount, real args, real window start).
531
+ * Amounts are summed per-token over all movements of the recorded tx
532
+ * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
533
+ * contains a price+timestamp satisfying each `oracle_price` leaf in the
534
+ * predicate so the intended call permits under every bound; the harness
535
+ * then mutates those entries (stale / missing / deviation / paused) to
536
+ * exercise the ORACLE_* deny paths. */
537
+ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
538
+ const amountByToken = {};
539
+ const totals = new Map();
540
+ for (const m of tx.tokenMovements) {
541
+ const current = totals.get(m.token) ?? 0n;
542
+ totals.set(m.token, current + BigInt(m.amount));
543
+ }
544
+ for (const [token, total] of totals) {
545
+ amountByToken[token] = total.toString();
546
+ }
547
+ const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt);
548
+ const ctx = {
549
+ contract: scope.contract,
550
+ fn: topLevel.fn,
551
+ args: topLevel.args.map(cloneScVal),
552
+ atLedger: tx.ledgerSequence,
553
+ nowSeconds: tx.fetchedAt,
554
+ amountByToken,
555
+ windowSpentByToken: {},
556
+ invocationCountByWindow: {},
557
+ oraclePriceByAsset,
558
+ };
559
+ if (userResponses?.validUntilLedger !== undefined) {
560
+ ctx.validUntilLedger = userResponses.validUntilLedger;
561
+ }
562
+ return ctx;
563
+ }
564
+ function cloneScVal(value) {
565
+ // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
566
+ // (decoded by the recorder). We clone top-level shells so the harness can
567
+ // mutate deny cases without aliasing the recorded call.
568
+ if (value.type === 'vec') {
569
+ return {
570
+ type: 'vec',
571
+ value: value.value.map(cloneScVal),
572
+ };
573
+ }
574
+ return { ...value };
575
+ }
576
+ /** Walk every `oracle_price` leaf in the predicate and return a price map
577
+ * whose entries satisfy the bound so the intended call permits. The
578
+ * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
579
+ * fresh-oracle deny case in `generateCases` is the only path that flips
580
+ * this map. Negatives are clamped at 0 - oracle prices are non-negative on
581
+ * Stellar. */
582
+ function oracleSatisfyingPrices(predicate, nowSeconds) {
583
+ const out = {};
584
+ visitOracleLeaves(predicate, (asset, op, bound) => {
585
+ let price;
586
+ switch (op) {
587
+ case 'lt':
588
+ case 'gt':
589
+ price = op === 'lt' ? bound - 1n : bound + 1n;
590
+ break;
591
+ case 'lte':
592
+ case 'gte':
593
+ case 'eq':
594
+ price = bound;
595
+ break;
596
+ }
597
+ if (price < 0n)
598
+ price = 0n;
599
+ out[asset] = { price: price.toString(), timestampSeconds: nowSeconds };
600
+ });
601
+ return out;
602
+ }
603
+ function visitOracleLeaves(node, visit) {
604
+ switch (node.op) {
605
+ case 'and':
606
+ case 'or':
607
+ for (const child of node.children)
608
+ visitOracleLeaves(child, visit);
609
+ return;
610
+ case 'not':
611
+ visitOracleLeaves(node.child, visit);
612
+ return;
613
+ case 'eq':
614
+ case 'lt':
615
+ case 'lte':
616
+ case 'gt':
617
+ case 'gte': {
618
+ const leftLeaf = node.left;
619
+ const rightLeaf = node.right;
620
+ const leftIsOracle = leftLeaf.kind === 'oracle_price';
621
+ const rightIsOracle = rightLeaf.kind === 'oracle_price';
622
+ let oracleAsset;
623
+ let literal;
624
+ if (leftIsOracle) {
625
+ oracleAsset = leftLeaf.asset;
626
+ literal = oracleLiteralFromLeaf(rightLeaf);
627
+ }
628
+ else if (rightIsOracle) {
629
+ oracleAsset = rightLeaf.asset;
630
+ literal = oracleLiteralFromLeaf(leftLeaf);
631
+ }
632
+ if (oracleAsset === undefined || literal === undefined)
633
+ return;
634
+ visit(oracleAsset, node.op, literal);
635
+ return;
636
+ }
637
+ case 'in':
638
+ // `in` is pure membership; oracle leaves inside haystacks are
639
+ // forbidden by the position rule, so there's nothing to set up here.
640
+ return;
641
+ }
642
+ }
643
+ function oracleLiteralFromLeaf(leaf) {
644
+ if (leaf.kind !== 'literal_i128')
645
+ return undefined;
646
+ try {
647
+ return BigInt(leaf.value);
648
+ }
649
+ catch {
650
+ return undefined;
651
+ }
652
+ }
package/dist/types.d.ts CHANGED
@@ -145,7 +145,7 @@ export type PredicateNode = {
145
145
  } | {
146
146
  op: 'in';
147
147
  needle: PredicateLeaf;
148
- haystack: PredicateLeaf[];
148
+ haystack: PredicateLeaf[]; /** set-valued only - the haystack is always sorted by canonical XDR bytes (pure membership). */
149
149
  };
150
150
  export type PredicateLeaf = {
151
151
  kind: 'call_contract';
@@ -160,6 +160,7 @@ export type PredicateLeaf = {
160
160
  } | {
161
161
  kind: 'window_spent';
162
162
  token: string;
163
+ windowSeconds: number;
163
164
  } | {
164
165
  kind: 'now';
165
166
  } | {
@@ -170,6 +171,27 @@ export type PredicateLeaf = {
170
171
  } | {
171
172
  kind: 'oracle_price';
172
173
  asset: string;
174
+ } | {
175
+ kind: 'literal_address';
176
+ value: string;
177
+ } | {
178
+ kind: 'literal_i128';
179
+ value: string;
180
+ } | {
181
+ kind: 'literal_symbol';
182
+ value: string;
183
+ } | {
184
+ kind: 'literal_u32';
185
+ value: number;
186
+ } | {
187
+ kind: 'literal_u64';
188
+ value: string;
189
+ } | {
190
+ kind: 'literal_bytes';
191
+ value: string;
192
+ } | {
193
+ kind: 'literal_vec';
194
+ elements: PredicateLeaf[];
173
195
  };
174
196
  export interface OZPrimitiveConfig {
175
197
  primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
@@ -0,0 +1,15 @@
1
+ export type SimulationResult = {
2
+ permit: {
3
+ tx: 'permit';
4
+ } | {
5
+ tx: 'deny';
6
+ reason: string;
7
+ };
8
+ evaluatedCases: Array<{
9
+ dimension: string;
10
+ outcome: 'permit' | 'deny';
11
+ reason: string;
12
+ }>;
13
+ backend: 'interpreter-v1' | 'ts-model';
14
+ simulatorVersion: string;
15
+ };
@@ -0,0 +1,22 @@
1
+ // src/verify/envelope.ts - the post-simulation result envelope used by
2
+ // review-card rendering and verification.
3
+ //
4
+ // `SimulationResult` is the structured verdict a `simulate_policy` run
5
+ // produces; the review-card builder reads it as one of its inputs (so the
6
+ // rendered card can quote the backend that evaluated the policy) and the
7
+ // verification pipeline reads `permit` + `evaluatedCases` to confirm every
8
+ // generated deny case really did deny.
9
+ //
10
+ // This envelope is intentionally separate from the CustodyAdapter
11
+ // `SimulationResult` in `src/seams/types.ts` (which is the dry-run stub
12
+ // returned by `adapter.simulate(ir, permitTx)`); the seam result is the
13
+ // adapter contract, this envelope is the post-simulation record consumed by
14
+ // downstream rendering + verification.
15
+ //
16
+ // Fields:
17
+ // - `permit` is the single verdict for the candidate recorded tx.
18
+ // - `evaluatedCases` is the deny-case battery outcome (every dimension
19
+ // must report `deny` when the policy is minimal).
20
+ // - `backend` is the actual evaluator that produced the verdict.
21
+ // - `simulatorVersion` lets the reviewer / audit log distinguish runs.
22
+ export {};
@@ -0,0 +1,3 @@
1
+ export type { SimulationResult } from './envelope.ts';
2
+ export { type SimulateOptions, simulatePolicy } from './simulate.ts';
3
+ export { type VerifyOptions, verifyPolicy } from './verify.ts';
@@ -0,0 +1,3 @@
1
+ // src/verify/index.ts - re-export the simulation / verification surface.
2
+ export { simulatePolicy } from "./simulate.js";
3
+ export { verifyPolicy } from "./verify.js";