@crediolabs/policy-synth 0.1.5 → 0.1.6

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 (62) hide show
  1. package/dist/record/decode.js +43 -0
  2. package/dist/record/freshness.d.ts +14 -1
  3. package/dist/record/freshness.js +32 -2
  4. package/dist/record/index.d.ts +11 -0
  5. package/dist/record/index.js +25 -0
  6. package/dist/record/movements.d.ts +15 -3
  7. package/dist/record/movements.js +42 -7
  8. package/dist/run/index.d.ts +13 -1
  9. package/dist/run/index.js +76 -18
  10. package/dist/run/schemas.d.ts +13 -0
  11. package/dist/run/schemas.js +8 -0
  12. package/dist/synth/address.d.ts +7 -0
  13. package/dist/synth/address.js +12 -0
  14. package/dist/synth/compose-from-recording.d.ts +4 -3
  15. package/dist/synth/compose-from-recording.js +16 -6
  16. package/dist/synth/deny-cases.d.ts +12 -2
  17. package/dist/synth/deny-cases.js +58 -2
  18. package/dist/synth/index.d.ts +1 -0
  19. package/dist/synth/index.js +4 -0
  20. package/dist/synth/minimize.d.ts +1 -1
  21. package/dist/synth/minimize.js +3 -3
  22. package/dist/synth/synthesize-from-recording.js +54 -6
  23. package/dist/types.d.ts +24 -1
  24. package/dist-cjs/record/decode.js +43 -0
  25. package/dist-cjs/record/freshness.d.ts +14 -1
  26. package/dist-cjs/record/freshness.js +32 -2
  27. package/dist-cjs/record/index.d.ts +11 -0
  28. package/dist-cjs/record/index.js +25 -0
  29. package/dist-cjs/record/movements.d.ts +15 -3
  30. package/dist-cjs/record/movements.js +42 -7
  31. package/dist-cjs/run/index.d.ts +13 -1
  32. package/dist-cjs/run/index.js +76 -17
  33. package/dist-cjs/run/schemas.d.ts +13 -0
  34. package/dist-cjs/run/schemas.js +8 -0
  35. package/dist-cjs/synth/address.d.ts +7 -0
  36. package/dist-cjs/synth/address.js +15 -0
  37. package/dist-cjs/synth/compose-from-recording.d.ts +4 -3
  38. package/dist-cjs/synth/compose-from-recording.js +16 -6
  39. package/dist-cjs/synth/deny-cases.d.ts +12 -2
  40. package/dist-cjs/synth/deny-cases.js +60 -2
  41. package/dist-cjs/synth/index.d.ts +1 -0
  42. package/dist-cjs/synth/index.js +6 -1
  43. package/dist-cjs/synth/minimize.d.ts +1 -1
  44. package/dist-cjs/synth/minimize.js +3 -3
  45. package/dist-cjs/synth/synthesize-from-recording.js +53 -5
  46. package/dist-cjs/types.d.ts +24 -1
  47. package/package.json +1 -1
  48. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
  49. package/src/record/corpus-fixtures.json +532 -0
  50. package/src/record/decode.ts +42 -0
  51. package/src/record/freshness.ts +34 -2
  52. package/src/record/index.ts +40 -0
  53. package/src/record/movements.ts +40 -7
  54. package/src/run/index.ts +80 -16
  55. package/src/run/schemas.ts +8 -0
  56. package/src/synth/address.ts +14 -0
  57. package/src/synth/compose-from-recording.ts +20 -9
  58. package/src/synth/deny-cases.ts +66 -2
  59. package/src/synth/index.ts +4 -0
  60. package/src/synth/minimize.ts +7 -3
  61. package/src/synth/synthesize-from-recording.ts +59 -6
  62. package/src/types.ts +18 -1
@@ -57,7 +57,7 @@ import {
57
57
  type ComposeUserResponses,
58
58
  composeFromRecording,
59
59
  } from './compose-from-recording.ts'
60
- import { generateCases } from './deny-cases.ts'
60
+ import { generateCases, ORIGINAL_DIMENSIONS } from './deny-cases.ts'
61
61
  import { type EvalContext, evaluate } from './evaluate.ts'
62
62
  import { runHarness } from './harness.ts'
63
63
  import { lower } from './lower.ts'
@@ -198,6 +198,33 @@ function synthesizeFromRecordingInner(
198
198
  }
199
199
  }
200
200
 
201
+ // 1a. Zero-invocation refusal (item 1). A recording with zero contract
202
+ // invocations cleared the parseConfidence gate legitimately (the
203
+ // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
204
+ // policy must scope to an authorized contract call. Refuse before any
205
+ // lower/scope work so the failure is specific and actionable. The
206
+ // recorder's silence is also made visible via `parseConfidence.noInvocations`
207
+ // so consumers can pattern-match without inferring from `invocations: []`
208
+ // next to `overall: 1.0`. The message does NOT ask for an ABI - the
209
+ // failure mode is the recording shape, not decoding coverage.
210
+ const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0
211
+ if (hasNoInvocations) {
212
+ return {
213
+ ok: false,
214
+ error: {
215
+ code: 'SYNTHESIS_ERROR',
216
+ message:
217
+ 'Recording contains no contract invocation to scope a policy to. Re-record a transaction that invokes a Soroban contract function (e.g. an SAC/SEP-41 transfer, a Blend yield-claim, or a SoroSwap swap).',
218
+ severity: 'error',
219
+ retryable: false,
220
+ details: {
221
+ invocations: tx.invocations.length,
222
+ noInvocationsMarker: tx.parseConfidence.noInvocations === true,
223
+ },
224
+ },
225
+ }
226
+ }
227
+
201
228
  // Bound the recording size fail-closed (defense-in-depth for direct callers;
202
229
  // the MCP schema caps this too).
203
230
  if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
@@ -404,9 +431,11 @@ function synthesizeFromRecordingInner(
404
431
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
405
432
 
406
433
  const finalPredicate: PredicateNode =
407
- startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
434
+ startingPredicate.op === 'and'
435
+ ? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
436
+ : startingPredicate
408
437
 
409
- const harnessCases = generateCases(finalPredicate, permitCtx)
438
+ const harnessCases = generateCases(finalPredicate, permitCtx, ORIGINAL_DIMENSIONS)
410
439
  const harnessResult = runHarness(finalPredicate, harnessCases)
411
440
  if (!harnessResult.ok) {
412
441
  return {
@@ -512,12 +541,26 @@ function synthesizeFromRecordingInner(
512
541
  policies: mergedRefs,
513
542
  }
514
543
 
544
+ // When nothing installable was synthesised (no interpreter doc AND no OZ
545
+ // policy refs), an empty `policies` array reads as "no restrictions" rather
546
+ // than "I synthesised nothing". Surface that explicitly so the empty result
547
+ // is never mistaken for a permissive policy - the context rule still exists,
548
+ // but it constrains nothing. (Kept as `{ok:true}` so the documented
549
+ // Path-A/Path-B demo behaviour is preserved - see F3.)
550
+ const zeroPolicyWarning =
551
+ mergedRefs.length === 0 && !interpreterPolicyDocument
552
+ ? [
553
+ 'No policy constraints were synthesised: the call to this contract is UNCONSTRAINED by this policy. Enable the interpreter (supply a smart account) to enforce the surfaced constraints.',
554
+ ]
555
+ : []
556
+
515
557
  const proposed: ProposedPolicy = {
516
558
  contextRule: mergedContextRule,
517
559
  policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
518
560
  policyRefs: mergedRefs,
519
561
  parseConfidence: { ...tx.parseConfidence },
520
562
  warnings: [
563
+ ...zeroPolicyWarning,
521
564
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
522
565
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
523
566
  ],
@@ -579,7 +622,7 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
579
622
  }
580
623
  if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
581
624
  return synthesisError(
582
- `limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`
625
+ `limitAmount must be a positive i128 decimal string within [1, ${I128_MAX}] (2^127-1), got: ${ur.limitAmount}`
583
626
  )
584
627
  }
585
628
  }
@@ -648,15 +691,25 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
648
691
  * fixture/LLM-seam marker. */
649
692
  const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
650
693
 
694
+ /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
695
+ * cannot be represented on-chain, so reject it at the synthesis boundary
696
+ * (fail-closed) instead of passing it through as an over-broad spending_limit.
697
+ * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
698
+ const I128_MAX = 2n ** 127n - 1n
699
+
651
700
  function isPositiveInt(n: number): boolean {
652
701
  return Number.isInteger(n) && n > 0
653
702
  }
654
703
 
655
- /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
704
+ /** True when `s` is a canonical positive decimal integer inside the signed-i128
705
+ * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
706
+ * it cannot be installed on-chain, and accepting it would emit a spending_limit
707
+ * with an effectively unbounded cap. */
656
708
  function isPositiveI128(s: string): boolean {
657
709
  if (!/^[0-9]+$/.test(s)) return false
658
710
  try {
659
- return BigInt(s) > 0n
711
+ const v = BigInt(s)
712
+ return v > 0n && v <= I128_MAX
660
713
  } catch {
661
714
  return false
662
715
  }
package/src/types.ts CHANGED
@@ -11,6 +11,11 @@ export type ScVal =
11
11
  | { type: 'symbol'; value: string }
12
12
  | { type: 'vec'; value: ScVal[] }
13
13
  | { type: 'bytes'; value: string }
14
+ /** Map<Symbol, ScVal> carried forward for SAC/SEP-41 transfer event data,
15
+ * which uses a Map shape (key "amount" -> I128) on Stellar mainnet. The
16
+ * entry key is captured as its symbol/form string so readAmount can route
17
+ * to the conventional field name without re-decoding the full XDR. */
18
+ | { type: 'map'; value: Array<{ key: string; val: ScVal }> }
14
19
  | { type: 'other'; value: string }
15
20
 
16
21
  /** OZ Accounts framework hard limits (verified against canonical OZ source). */
@@ -241,7 +246,15 @@ export type AmbiguityCode =
241
246
 
242
247
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
243
248
  * breakdown is the diagnostic the user / agent sees when the gate fires. The
244
- * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
249
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`.
250
+ *
251
+ * `noInvocations` (item 1) is a recorder-side marker set ONLY when the envelope
252
+ * contained zero `invokeContract` host functions (e.g. a `createContract` or
253
+ * `uploadContractWasm` op). The math is unchanged: that case still scores 1.0
254
+ * via the `denom === 0` guard. The marker exists so a consumer does not have
255
+ * to infer the situation from `invocations.length === 0` next to `overall: 1.0`
256
+ * — a downstream synth refuses such recordings with a specific, actionable
257
+ * error rather than silently producing a useless empty scope. */
245
258
  export interface ParseConfidence {
246
259
  overall: number // 0..1
247
260
  knownContracts: string[]
@@ -251,4 +264,8 @@ export interface ParseConfidence {
251
264
  }>
252
265
  opaqueScVals: Array<{ path: string; type: string }>
253
266
  thresholdUsed: number // 1.0 by default; > 0 only via explicit confidence_override
267
+ /** True iff the recorder decoded zero contract invocations. Absent on
268
+ * recordings that pre-date this field; consumers should treat `undefined`
269
+ * as "no marker" and rely on `invocations.length` as the fallback. */
270
+ noInvocations?: true
254
271
  }