@crediolabs/policy-synth 0.1.4 → 0.1.5
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.
- package/LICENSE +21 -0
- package/dist/codegen/template.js +3 -3
- package/dist/record/decode.d.ts +6 -3
- package/dist/record/decode.js +13 -3
- package/dist/run/index.d.ts +31 -0
- package/dist/run/index.js +164 -0
- package/dist/run/schemas.d.ts +2003 -0
- package/dist/run/schemas.js +215 -0
- package/dist/synth/deny-cases.js +18 -2
- package/dist/synth/evaluate.js +2 -2
- package/dist/synth/synthesize-from-recording.d.ts +5 -2
- package/dist/synth/synthesize-from-recording.js +121 -63
- package/dist/types.d.ts +17 -0
- package/dist/types.js +17 -0
- package/dist/verify/simulate.js +18 -2
- package/dist/verify/verify.js +18 -2
- package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
- package/dist-cjs/adapters/interpreter/adapter.js +527 -0
- package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
- package/dist-cjs/adapters/interpreter/index.js +8 -0
- package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
- package/dist-cjs/adapters/oz/adapter.js +289 -0
- package/dist-cjs/adapters/oz/index.d.ts +1 -0
- package/dist-cjs/adapters/oz/index.js +8 -0
- package/dist-cjs/codegen/compile-gate.d.ts +33 -0
- package/dist-cjs/codegen/compile-gate.js +123 -0
- package/dist-cjs/codegen/index.d.ts +2 -0
- package/dist-cjs/codegen/index.js +14 -0
- package/dist-cjs/codegen/template.d.ts +18 -0
- package/dist-cjs/codegen/template.js +134 -0
- package/dist-cjs/errors.d.ts +37 -0
- package/dist-cjs/errors.js +3 -0
- package/dist-cjs/index.d.ts +11 -0
- package/dist-cjs/index.js +27 -0
- package/dist-cjs/ir/index.d.ts +1 -0
- package/dist-cjs/ir/index.js +3 -0
- package/dist-cjs/ir/types.d.ts +108 -0
- package/dist-cjs/ir/types.js +12 -0
- package/dist-cjs/mandate/index.d.ts +2 -0
- package/dist-cjs/mandate/index.js +6 -0
- package/dist-cjs/mandate/to-ir.d.ts +3 -0
- package/dist-cjs/mandate/to-ir.js +63 -0
- package/dist-cjs/mandate/types.d.ts +20 -0
- package/dist-cjs/mandate/types.js +9 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/predicate/encode.d.ts +10 -0
- package/dist-cjs/predicate/encode.js +252 -0
- package/dist-cjs/predicate/index.d.ts +1 -0
- package/dist-cjs/predicate/index.js +6 -0
- package/dist-cjs/record/decode.d.ts +79 -0
- package/dist-cjs/record/decode.js +398 -0
- package/dist-cjs/record/freshness.d.ts +17 -0
- package/dist-cjs/record/freshness.js +55 -0
- package/dist-cjs/record/index.d.ts +21 -0
- package/dist-cjs/record/index.js +166 -0
- package/dist-cjs/record/movements.d.ts +20 -0
- package/dist-cjs/record/movements.js +192 -0
- package/dist-cjs/record/rpc.d.ts +22 -0
- package/dist-cjs/record/rpc.js +74 -0
- package/dist-cjs/record/validate.d.ts +22 -0
- package/dist-cjs/record/validate.js +63 -0
- package/dist-cjs/registry/identify.d.ts +11 -0
- package/dist-cjs/registry/identify.js +87 -0
- package/dist-cjs/registry/index.d.ts +3 -0
- package/dist-cjs/registry/index.js +15 -0
- package/dist-cjs/registry/known-addresses.d.ts +16 -0
- package/dist-cjs/registry/known-addresses.js +53 -0
- package/dist-cjs/registry/protocols.d.ts +38 -0
- package/dist-cjs/registry/protocols.js +153 -0
- package/dist-cjs/review-card/builder.d.ts +14 -0
- package/dist-cjs/review-card/builder.js +264 -0
- package/dist-cjs/review-card/conflict.d.ts +40 -0
- package/dist-cjs/review-card/conflict.js +114 -0
- package/dist-cjs/review-card/cross-check.d.ts +11 -0
- package/dist-cjs/review-card/cross-check.js +151 -0
- package/dist-cjs/review-card/index.d.ts +3 -0
- package/dist-cjs/review-card/index.js +10 -0
- package/dist-cjs/run/index.d.ts +31 -0
- package/dist-cjs/run/index.js +178 -0
- package/dist-cjs/run/schemas.d.ts +2003 -0
- package/dist-cjs/run/schemas.js +218 -0
- package/dist-cjs/seams/index.d.ts +1 -0
- package/dist-cjs/seams/index.js +3 -0
- package/dist-cjs/seams/types.d.ts +66 -0
- package/dist-cjs/seams/types.js +12 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
- package/dist-cjs/synth/compose-from-recording.js +356 -0
- package/dist-cjs/synth/deny-cases.d.ts +12 -0
- package/dist-cjs/synth/deny-cases.js +380 -0
- package/dist-cjs/synth/evaluate.d.ts +39 -0
- package/dist-cjs/synth/evaluate.js +428 -0
- package/dist-cjs/synth/harness.d.ts +16 -0
- package/dist-cjs/synth/harness.js +29 -0
- package/dist-cjs/synth/index.d.ts +9 -0
- package/dist-cjs/synth/index.js +23 -0
- package/dist-cjs/synth/lower.d.ts +23 -0
- package/dist-cjs/synth/lower.js +119 -0
- package/dist-cjs/synth/minimize.d.ts +4 -0
- package/dist-cjs/synth/minimize.js +41 -0
- package/dist-cjs/synth/predicate-literals.d.ts +5 -0
- package/dist-cjs/synth/predicate-literals.js +28 -0
- package/dist-cjs/synth/scope.d.ts +26 -0
- package/dist-cjs/synth/scope.js +81 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
- package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
- package/dist-cjs/synth/synthesize-from-recording.js +711 -0
- package/dist-cjs/types.d.ts +288 -0
- package/dist-cjs/types.js +55 -0
- package/dist-cjs/verify/envelope.d.ts +15 -0
- package/dist-cjs/verify/envelope.js +23 -0
- package/dist-cjs/verify/index.d.ts +3 -0
- package/dist-cjs/verify/index.js +8 -0
- package/dist-cjs/verify/simulate.d.ts +31 -0
- package/dist-cjs/verify/simulate.js +261 -0
- package/dist-cjs/verify/verify.d.ts +21 -0
- package/dist-cjs/verify/verify.js +192 -0
- package/package.json +43 -3
- package/src/codegen/template.ts +3 -3
- package/src/record/decode.ts +21 -10
- package/src/run/index.ts +213 -0
- package/src/run/schemas.ts +242 -0
- package/src/synth/deny-cases.ts +21 -2
- package/src/synth/evaluate.ts +2 -2
- package/src/synth/synthesize-from-recording.ts +141 -62
- package/src/types.ts +19 -0
- package/src/verify/simulate.ts +21 -2
- package/src/verify/verify.ts +21 -2
|
@@ -43,6 +43,7 @@ import type { ToolError, ToolResponse } from '../errors.ts'
|
|
|
43
43
|
import { encodePredicate } from '../predicate/encode.ts'
|
|
44
44
|
import {
|
|
45
45
|
type ContractInvocation,
|
|
46
|
+
MAX_SCVAL_CLONE_DEPTH,
|
|
46
47
|
type Network,
|
|
47
48
|
OZ_LIMITS,
|
|
48
49
|
type PredicateLeaf,
|
|
@@ -102,16 +103,86 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
102
103
|
interpreter?: InterpreterAdapterOptions
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
106
106
|
export function synthesizeFromRecording(
|
|
107
107
|
tx: RecordedTransaction,
|
|
108
108
|
opts: SynthesizeFromRecordingOptions,
|
|
109
109
|
ozConfig: OzAdapterConfig
|
|
110
|
+
): ToolResponse<ProposedPolicy> {
|
|
111
|
+
// Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
|
|
112
|
+
// with a string `.code`) inside the body is converted to a structured
|
|
113
|
+
// `{ok:false, error}`; anything else is rethrown so genuine bugs crash
|
|
114
|
+
// instead of being silently swallowed. The envelope must wrap the entire
|
|
115
|
+
// synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
|
|
116
|
+
// TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
|
|
117
|
+
// a structured ToolError rather than a thrown RangeError.
|
|
118
|
+
try {
|
|
119
|
+
return synthesizeFromRecordingInner(tx, opts, ozConfig)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (isToolErrorShape(e)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: {
|
|
125
|
+
code: e.code as ToolError['code'],
|
|
126
|
+
message: e.message,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
retryable: false,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw e
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True when `e` is the ToolError-shaped throw the internal pipeline uses to
|
|
137
|
+
* signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
|
|
138
|
+
* depth-cap throws). Detected by a string `code` field — the only contract
|
|
139
|
+
* the body's internal helpers agree on. */
|
|
140
|
+
function isToolErrorShape(e: unknown): e is { code: string; message: string } {
|
|
141
|
+
if (e === null || typeof e !== 'object') return false
|
|
142
|
+
const code = (e as { code?: unknown }).code
|
|
143
|
+
const message = (e as { message?: unknown }).message
|
|
144
|
+
return typeof code === 'string' && typeof message === 'string'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** ToolError-shaped error helper used by the body to surface a structured
|
|
148
|
+
* failure that the envelope converts to `{ok:false, error}`. */
|
|
149
|
+
function throwToolError(code: ToolError['code'], message: string): never {
|
|
150
|
+
const err = new Error(message) as Error & { code: string; severity: string; retryable: boolean }
|
|
151
|
+
err.code = code
|
|
152
|
+
err.severity = 'error'
|
|
153
|
+
err.retryable = false
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
158
|
+
function synthesizeFromRecordingInner(
|
|
159
|
+
tx: RecordedTransaction,
|
|
160
|
+
opts: SynthesizeFromRecordingOptions,
|
|
161
|
+
ozConfig: OzAdapterConfig
|
|
110
162
|
): ToolResponse<ProposedPolicy> {
|
|
111
163
|
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
112
164
|
const invalid = validateOptions(opts)
|
|
113
165
|
if (invalid) return { ok: false, error: invalid }
|
|
114
166
|
|
|
167
|
+
// 0a. per-movement amount validation (item 2). Synthesizing from a recording
|
|
168
|
+
// whose `tokenMovements[].amount` does not match the canonical positive
|
|
169
|
+
// decimal integer format is a malformed-input failure, not a runtime
|
|
170
|
+
// one - reject it here so the downstream `BigInt(m.amount)` in
|
|
171
|
+
// `buildPermitContext` cannot throw a SyntaxError past the envelope.
|
|
172
|
+
for (const [i, m] of tx.tokenMovements.entries()) {
|
|
173
|
+
if (!/^[0-9]+$/.test(m.amount)) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: {
|
|
177
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
178
|
+
message: `tokenMovements[${i}].amount must be a positive decimal integer string, got: ${m.amount}`,
|
|
179
|
+
severity: 'error',
|
|
180
|
+
retryable: false,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
115
186
|
// 1. parseConfidence gate.
|
|
116
187
|
const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
|
|
117
188
|
if (tx.parseConfidence.overall < threshold) {
|
|
@@ -154,9 +225,6 @@ export function synthesizeFromRecording(
|
|
|
154
225
|
if (!scopeRes.ok) return scopeRes
|
|
155
226
|
const scope: ScopeDecision = scopeRes.data
|
|
156
227
|
if (scope.kind !== 'call_contract') {
|
|
157
|
-
// A default scope on the recording-path is not currently a covered flow;
|
|
158
|
-
// surface SYNTHESIS_ERROR so the orchestrator never silently emits an
|
|
159
|
-
// over-broad default-scoped policy.
|
|
160
228
|
return {
|
|
161
229
|
ok: false,
|
|
162
230
|
error: {
|
|
@@ -196,17 +264,6 @@ export function synthesizeFromRecording(
|
|
|
196
264
|
|
|
197
265
|
// 6. Interpreter compile (opt-in; fail-closed when the user routed real
|
|
198
266
|
// restrictions to the interpreter).
|
|
199
|
-
//
|
|
200
|
-
// When the caller opts in, the interpreter always runs - even for a
|
|
201
|
-
// scope-only interpreter IR (no routed constraints) - because the
|
|
202
|
-
// interpreter adapter lowers `scope.contract` + `scope.method` into a
|
|
203
|
-
// `call_contract == ...` and `call_fn == ...` predicate sibling pair.
|
|
204
|
-
// This is the real per-method enforcement the OZ adapter cannot do.
|
|
205
|
-
//
|
|
206
|
-
// When the caller does NOT opt in: today's behaviour is preserved. The
|
|
207
|
-
// compose step routes every constraint to the OZ IR; OZ flags the ones
|
|
208
|
-
// it cannot lower as `uncovered` and the orchestrator surfaces them as
|
|
209
|
-
// warnings. No interpreter doc is produced.
|
|
210
267
|
const interpreterOpts = opts.interpreter
|
|
211
268
|
|
|
212
269
|
let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
|
|
@@ -220,18 +277,6 @@ export function synthesizeFromRecording(
|
|
|
220
277
|
...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
|
|
221
278
|
}
|
|
222
279
|
|
|
223
|
-
// The starting PredicateNode the self-verify + minimise pipeline drives.
|
|
224
|
-
// Two paths populate it:
|
|
225
|
-
// - Test seam (`__testPredicateNode` set): use it directly, skipping the
|
|
226
|
-
// adapter's compile(). Production code MUST NOT set the seam; it
|
|
227
|
-
// exists so the pipeline can be exercised on hand-crafted predicates
|
|
228
|
-
// (redundant conjuncts for minimise, deliberately over-broad leaves
|
|
229
|
-
// for DENY_CASE_FAILURE).
|
|
230
|
-
// - Normal: call the interpreter adapter's compile() so the existing
|
|
231
|
-
// enforcement gates (SCOPE_SELF_CALL / ORACLE_LEAF_INVALID_POSITION /
|
|
232
|
-
// ORACLE_PARAMS_OUT_OF_RANGE + uncovered reporting) fire first, then
|
|
233
|
-
// re-derive the pre-encoding PredicateNode via `lowerRuleToPredicate`
|
|
234
|
-
// so minimise + harness see the same shape the encoder will see.
|
|
235
280
|
let startingPredicate: PredicateNode | null = null
|
|
236
281
|
const testSeam = interpreterOpts.__testPredicateNode
|
|
237
282
|
if (testSeam !== undefined) {
|
|
@@ -243,11 +288,6 @@ export function synthesizeFromRecording(
|
|
|
243
288
|
try {
|
|
244
289
|
interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
|
|
245
290
|
} catch (e) {
|
|
246
|
-
// Interpreter enforcement-gate throws (SCOPE_SELF_CALL,
|
|
247
|
-
// ORACLE_LEAF_INVALID_POSITION, ORACLE_PARAMS_OUT_OF_RANGE). Surface
|
|
248
|
-
// the gate code so the caller can act; never install an OZ-only
|
|
249
|
-
// partial policy - the user explicitly asked for these constraints
|
|
250
|
-
// to be enforced and silently dropping them yields an over-broad rule.
|
|
251
291
|
const code = (e as { code?: ToolError['code'] }).code
|
|
252
292
|
const allowedCodes: ToolError['code'][] = [
|
|
253
293
|
'SCOPE_SELF_CALL',
|
|
@@ -338,15 +378,8 @@ export function synthesizeFromRecording(
|
|
|
338
378
|
}
|
|
339
379
|
}
|
|
340
380
|
|
|
341
|
-
// 6b. Self-verify + minimise.
|
|
342
|
-
// from the recorded tx + synthesised constraints so the harness
|
|
343
|
-
// reasons about the intended call (the only one the user actually
|
|
344
|
-
// recorded). The recorded call MUST permit under the predicate; the
|
|
345
|
-
// generated deny battery MUST all deny.
|
|
381
|
+
// 6b. Self-verify + minimise.
|
|
346
382
|
if (!topLevel) {
|
|
347
|
-
// The orchestrator guarantees this for the call_contract branch above;
|
|
348
|
-
// a default-scoped policy never reaches the interpreter block. Fail
|
|
349
|
-
// closed rather than attempt to self-verify a vague shape.
|
|
350
383
|
return {
|
|
351
384
|
ok: false,
|
|
352
385
|
error: {
|
|
@@ -370,10 +403,6 @@ export function synthesizeFromRecording(
|
|
|
370
403
|
}
|
|
371
404
|
const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
|
|
372
405
|
|
|
373
|
-
// Top-level `and` predicates are what generateCases + minimize assume;
|
|
374
|
-
// for any other shape we skip minimise (encode as-is) but still assert
|
|
375
|
-
// the permit case permits. An over-broad non-and predicate is still
|
|
376
|
-
// caught by the harness on the next step.
|
|
377
406
|
const finalPredicate: PredicateNode =
|
|
378
407
|
startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
|
|
379
408
|
|
|
@@ -407,15 +436,13 @@ export function synthesizeFromRecording(
|
|
|
407
436
|
}
|
|
408
437
|
|
|
409
438
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
410
|
-
// canonical bytes back onto the PolicyDocument + PolicyRef.
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
439
|
+
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
440
|
+
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
441
|
+
// breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
|
|
442
|
+
// envelope converts them to a structured `{ok:false, error}`.
|
|
414
443
|
const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
|
|
415
444
|
|
|
416
445
|
if (testSeam !== undefined) {
|
|
417
|
-
// Test seam: build the PolicyDocument + PolicyRef now (compile() was
|
|
418
|
-
// skipped). Shape mirrors the adapter's output exactly.
|
|
419
446
|
interpreterPolicyDocument = {
|
|
420
447
|
grammarVersion: 1,
|
|
421
448
|
installNonce: interpreterOpts.installNonce ?? 1,
|
|
@@ -455,17 +482,14 @@ export function synthesizeFromRecording(
|
|
|
455
482
|
}
|
|
456
483
|
|
|
457
484
|
// When the interpreter succeeds, OZ-side `uncovered` warnings that the
|
|
458
|
-
// interpreter actually lowered
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// interpreter did. Drop those so the user-facing warnings reflect what is
|
|
462
|
-
// still UN-enforced, not what OZ alone could not do.
|
|
485
|
+
// interpreter actually lowered are misleading: OZ really did not lower them,
|
|
486
|
+
// but the interpreter did. Drop those so the user-facing warnings reflect
|
|
487
|
+
// what is still UN-enforced, not what OZ alone could not do.
|
|
463
488
|
const ozUncovered = interpreterPolicyRef
|
|
464
489
|
? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
|
|
465
490
|
: compileRes.uncovered
|
|
466
491
|
|
|
467
|
-
// 7. Merge into the OZ-shaped ProposedPolicy.
|
|
468
|
-
// [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
|
|
492
|
+
// 7. Merge into the OZ-shaped ProposedPolicy.
|
|
469
493
|
const ozRefs = compileRes.proposed.policyRefs
|
|
470
494
|
const mergedRefs: ProposedPolicy['policyRefs'] = []
|
|
471
495
|
if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
|
|
@@ -502,6 +526,10 @@ export function synthesizeFromRecording(
|
|
|
502
526
|
return { ok: true, data: proposed }
|
|
503
527
|
}
|
|
504
528
|
|
|
529
|
+
// Re-export the throwToolError helper for callers that need to surface a
|
|
530
|
+
// ToolError-shaped throw inside the body (e.g. tests).
|
|
531
|
+
export { throwToolError }
|
|
532
|
+
|
|
505
533
|
/** OZ-side `uncovered` warning patterns that the interpreter adapter
|
|
506
534
|
* actually lowers when wired in. When the interpreter adapter succeeds, we
|
|
507
535
|
* drop matching entries from the OZ uncovered list so the user-facing
|
|
@@ -562,14 +590,31 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
562
590
|
`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
|
|
563
591
|
)
|
|
564
592
|
}
|
|
593
|
+
// Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
|
|
594
|
+
// C.../56-char shape check so a fixture/LLM-seam marker is reported
|
|
595
|
+
// with the specific placeholder error (not a generic "must be a
|
|
596
|
+
// C... contract" message). A real install routes a C... contract
|
|
597
|
+
// address derivable from the on-chain account; any
|
|
598
|
+
// 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
|
|
599
|
+
// fixture marker that must never reach the install payload.
|
|
600
|
+
if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
|
|
601
|
+
return synthesisError(
|
|
602
|
+
`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
|
|
603
|
+
)
|
|
604
|
+
}
|
|
565
605
|
if (!isContractAddress(sa)) {
|
|
566
606
|
return synthesisError(
|
|
567
607
|
`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
|
|
568
608
|
)
|
|
569
609
|
}
|
|
570
610
|
const nonce = opts.interpreter.installNonce
|
|
571
|
-
|
|
572
|
-
|
|
611
|
+
// Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
|
|
612
|
+
// u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
|
|
613
|
+
// the validUntilLedger SOROBAN_LIMITS.u32Max check above.
|
|
614
|
+
if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
|
|
615
|
+
return synthesisError(
|
|
616
|
+
`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
|
|
617
|
+
)
|
|
573
618
|
}
|
|
574
619
|
const op = opts.interpreter.oracleParams
|
|
575
620
|
if (op) {
|
|
@@ -596,6 +641,13 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
596
641
|
return null
|
|
597
642
|
}
|
|
598
643
|
|
|
644
|
+
/** Placeholder/stub smart-account prefixes (item 5). Mirrors the
|
|
645
|
+
* `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
|
|
646
|
+
* the interpreter-contract strkey; a real install must point at a C...
|
|
647
|
+
* contract address derivable from the on-chain account, never a
|
|
648
|
+
* fixture/LLM-seam marker. */
|
|
649
|
+
const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
|
|
650
|
+
|
|
599
651
|
function isPositiveInt(n: number): boolean {
|
|
600
652
|
return Number.isInteger(n) && n > 0
|
|
601
653
|
}
|
|
@@ -689,19 +741,46 @@ function buildPermitContext(
|
|
|
689
741
|
return ctx
|
|
690
742
|
}
|
|
691
743
|
|
|
692
|
-
function cloneScVal(
|
|
744
|
+
function cloneScVal(
|
|
745
|
+
value: { type: string; value: unknown },
|
|
746
|
+
depth = 0
|
|
747
|
+
): ContractInvocation['args'][number] {
|
|
693
748
|
// EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
|
|
694
749
|
// (decoded by the recorder). We clone top-level shells so the harness can
|
|
695
|
-
// mutate deny cases without aliasing the recorded call.
|
|
750
|
+
// mutate deny cases without aliasing the recorded call. Recursion is
|
|
751
|
+
// bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
|
|
752
|
+
// cannot RangeError the JS stack; the over-depth branch throws a
|
|
753
|
+
// ToolError-shaped error that the `synthesizeFromRecording` envelope
|
|
754
|
+
// (item 3) converts to `{ok:false, error}`.
|
|
696
755
|
if (value.type === 'vec') {
|
|
756
|
+
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
757
|
+
throw cloneDepthError(value)
|
|
758
|
+
}
|
|
697
759
|
return {
|
|
698
760
|
type: 'vec',
|
|
699
|
-
value: (value.value as ContractInvocation['args'][number][]).map(
|
|
761
|
+
value: (value.value as ContractInvocation['args'][number][]).map((v) =>
|
|
762
|
+
cloneScVal(v, depth + 1)
|
|
763
|
+
),
|
|
700
764
|
}
|
|
701
765
|
}
|
|
702
766
|
return { ...value } as ContractInvocation['args'][number]
|
|
703
767
|
}
|
|
704
768
|
|
|
769
|
+
/** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
|
|
770
|
+
* The shape (object with string `code`) is what the `synthesizeFromRecording`
|
|
771
|
+
* envelope detects and converts to a structured `{ok:false, error}`. */
|
|
772
|
+
function cloneDepthError(value: { type: string; value: unknown }): never {
|
|
773
|
+
const err = new Error(
|
|
774
|
+
`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
|
|
775
|
+
) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
|
|
776
|
+
err.code = 'SYNTHESIS_ERROR'
|
|
777
|
+
err.severity = 'error'
|
|
778
|
+
err.retryable = false
|
|
779
|
+
// Tag the offending value so failures are diagnosable.
|
|
780
|
+
err.depthContext = value.type
|
|
781
|
+
throw err
|
|
782
|
+
}
|
|
783
|
+
|
|
705
784
|
/** Walk every `oracle_price` leaf in the predicate and return a price map
|
|
706
785
|
* whose entries satisfy the bound so the intended call permits. The
|
|
707
786
|
* timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
|
package/src/types.ts
CHANGED
|
@@ -33,6 +33,25 @@ export const SOROBAN_LIMITS = {
|
|
|
33
33
|
secondsPerLedger: 5,
|
|
34
34
|
} as const
|
|
35
35
|
|
|
36
|
+
/** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
|
|
37
|
+
* A real Stellar tx caps nesting well below this; the bound hard-caps a
|
|
38
|
+
* hand-crafted payload so the recorder fails CLOSED (records the over-depth
|
|
39
|
+
* branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
|
|
40
|
+
* Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
|
|
41
|
+
* already enforces; lifted to 30 so legitimate nested args (a path vec
|
|
42
|
+
* inside an order vec inside a market-data vec) decode cleanly while a
|
|
43
|
+
* malformed ~10000-deep payload still trips the cap. */
|
|
44
|
+
export const MAX_SCVAL_DEPTH = 30 as const
|
|
45
|
+
|
|
46
|
+
/** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
|
|
47
|
+
* the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
|
|
48
|
+
* so a hand-crafted nested-vec payload cannot RangeError the JS stack during
|
|
49
|
+
* context building. Over-depth throws a ToolError (caught by the
|
|
50
|
+
* `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
|
|
51
|
+
* the caller gets a structured `{ok:false, error}` instead of a thrown
|
|
52
|
+
* RangeError. */
|
|
53
|
+
export const MAX_SCVAL_CLONE_DEPTH = 30 as const
|
|
54
|
+
|
|
36
55
|
/** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
|
|
37
56
|
* AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
|
|
38
57
|
* byte-identical (TS reads the same JSON manifest the Rust build emits). If they
|
package/src/verify/simulate.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
RecordedTransaction,
|
|
38
38
|
ScVal,
|
|
39
39
|
} from '../types.ts'
|
|
40
|
+
import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
|
|
40
41
|
import type { SimulationResult } from './envelope.ts'
|
|
41
42
|
|
|
42
43
|
const SIMULATOR_VERSION = 'ts-model-1.0.0'
|
|
@@ -227,13 +228,31 @@ function buildPermitContext(
|
|
|
227
228
|
return ctx
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
function cloneScVal(value: ScVal): ScVal {
|
|
231
|
+
function cloneScVal(value: ScVal, depth = 0): ScVal {
|
|
232
|
+
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
233
|
+
// payload cannot RangeError the JS stack during context building. Over-depth
|
|
234
|
+
// throws a ToolError-shaped error that the simulator's existing try/catch
|
|
235
|
+
// converts to a structured `{ok:false, error}` (not a thrown RangeError).
|
|
231
236
|
if (value.type === 'vec') {
|
|
232
|
-
|
|
237
|
+
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
238
|
+
throw cloneDepthError(value)
|
|
239
|
+
}
|
|
240
|
+
return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
|
|
233
241
|
}
|
|
234
242
|
return { ...value }
|
|
235
243
|
}
|
|
236
244
|
|
|
245
|
+
function cloneDepthError(value: ScVal): never {
|
|
246
|
+
const err = new Error(
|
|
247
|
+
`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
|
|
248
|
+
) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
|
|
249
|
+
err.code = 'SIMULATION_ERROR'
|
|
250
|
+
err.severity = 'error'
|
|
251
|
+
err.retryable = false
|
|
252
|
+
err.depthContext = value.type
|
|
253
|
+
throw err
|
|
254
|
+
}
|
|
255
|
+
|
|
237
256
|
function visitOracleLeaves(
|
|
238
257
|
node: PredicateNode,
|
|
239
258
|
visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
|
package/src/verify/verify.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { ToolError, ToolResponse } from '../errors.ts'
|
|
|
32
32
|
import type { EvalContext } from '../synth/evaluate.ts'
|
|
33
33
|
import { minimize } from '../synth/minimize.ts'
|
|
34
34
|
import type { ContractInvocation, PredicateNode, RecordedTransaction, ScVal } from '../types.ts'
|
|
35
|
+
import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
|
|
35
36
|
|
|
36
37
|
/** Options for `verifyPolicy`. Mirrors the orchestrator's permit-context
|
|
37
38
|
* knobs (expiry; oracle fixture) so verify sees the same shape
|
|
@@ -171,13 +172,31 @@ function buildPermitContextForVerify(
|
|
|
171
172
|
return ctx
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
function cloneScVal(value: ScVal): ScVal {
|
|
175
|
+
function cloneScVal(value: ScVal, depth = 0): ScVal {
|
|
176
|
+
// Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
|
|
177
|
+
// payload cannot RangeError the JS stack during context building. Over-depth
|
|
178
|
+
// throws a ToolError-shaped error that the verifier's existing try/catch
|
|
179
|
+
// converts to a structured `{ok:false, error}` (not a thrown RangeError).
|
|
175
180
|
if (value.type === 'vec') {
|
|
176
|
-
|
|
181
|
+
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
182
|
+
throw cloneDepthError(value)
|
|
183
|
+
}
|
|
184
|
+
return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
|
|
177
185
|
}
|
|
178
186
|
return { ...value }
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
function cloneDepthError(value: ScVal): never {
|
|
190
|
+
const err = new Error(
|
|
191
|
+
`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
|
|
192
|
+
) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
|
|
193
|
+
err.code = 'VERIFICATION_FAILED'
|
|
194
|
+
err.severity = 'error'
|
|
195
|
+
err.retryable = false
|
|
196
|
+
err.depthContext = value.type
|
|
197
|
+
throw err
|
|
198
|
+
}
|
|
199
|
+
|
|
181
200
|
function fingerprint(node: PredicateNode): string {
|
|
182
201
|
return JSON.stringify(node, replacer)
|
|
183
202
|
}
|