@crediolabs/policy-synth 0.1.4 → 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.
- package/LICENSE +21 -0
- package/dist/codegen/template.js +3 -3
- package/dist/record/decode.d.ts +6 -3
- package/dist/record/decode.js +56 -3
- package/dist/record/freshness.d.ts +14 -1
- package/dist/record/freshness.js +32 -2
- package/dist/record/index.d.ts +11 -0
- package/dist/record/index.js +25 -0
- package/dist/record/movements.d.ts +15 -3
- package/dist/record/movements.js +42 -7
- package/dist/run/index.d.ts +43 -0
- package/dist/run/index.js +222 -0
- package/dist/run/schemas.d.ts +2016 -0
- package/dist/run/schemas.js +223 -0
- package/dist/synth/address.d.ts +7 -0
- package/dist/synth/address.js +12 -0
- package/dist/synth/compose-from-recording.d.ts +4 -3
- package/dist/synth/compose-from-recording.js +16 -6
- package/dist/synth/deny-cases.d.ts +12 -2
- package/dist/synth/deny-cases.js +76 -4
- package/dist/synth/evaluate.js +2 -2
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +4 -0
- package/dist/synth/minimize.d.ts +1 -1
- package/dist/synth/minimize.js +3 -3
- package/dist/synth/synthesize-from-recording.d.ts +5 -2
- package/dist/synth/synthesize-from-recording.js +175 -69
- package/dist/types.d.ts +41 -1
- 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 +441 -0
- package/dist-cjs/record/freshness.d.ts +30 -0
- package/dist-cjs/record/freshness.js +85 -0
- package/dist-cjs/record/index.d.ts +32 -0
- package/dist-cjs/record/index.js +191 -0
- package/dist-cjs/record/movements.d.ts +32 -0
- package/dist-cjs/record/movements.js +227 -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 +43 -0
- package/dist-cjs/run/index.js +237 -0
- package/dist-cjs/run/schemas.d.ts +2016 -0
- package/dist-cjs/run/schemas.js +226 -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/address.d.ts +7 -0
- package/dist-cjs/synth/address.js +15 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +65 -0
- package/dist-cjs/synth/compose-from-recording.js +366 -0
- package/dist-cjs/synth/deny-cases.d.ts +22 -0
- package/dist-cjs/synth/deny-cases.js +438 -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 +10 -0
- package/dist-cjs/synth/index.js +28 -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 +759 -0
- package/dist-cjs/types.d.ts +311 -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/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
- package/src/record/corpus-fixtures.json +532 -0
- package/src/record/decode.ts +63 -10
- package/src/record/freshness.ts +34 -2
- package/src/record/index.ts +40 -0
- package/src/record/movements.ts +40 -7
- package/src/run/index.ts +277 -0
- package/src/run/schemas.ts +250 -0
- package/src/synth/address.ts +14 -0
- package/src/synth/compose-from-recording.ts +20 -9
- package/src/synth/deny-cases.ts +87 -4
- package/src/synth/evaluate.ts +2 -2
- package/src/synth/index.ts +4 -0
- package/src/synth/minimize.ts +7 -3
- package/src/synth/synthesize-from-recording.ts +200 -68
- package/src/types.ts +37 -1
- 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,
|
|
@@ -56,7 +57,7 @@ import {
|
|
|
56
57
|
type ComposeUserResponses,
|
|
57
58
|
composeFromRecording,
|
|
58
59
|
} from './compose-from-recording.ts'
|
|
59
|
-
import { generateCases } from './deny-cases.ts'
|
|
60
|
+
import { generateCases, ORIGINAL_DIMENSIONS } from './deny-cases.ts'
|
|
60
61
|
import { type EvalContext, evaluate } from './evaluate.ts'
|
|
61
62
|
import { runHarness } from './harness.ts'
|
|
62
63
|
import { lower } from './lower.ts'
|
|
@@ -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) {
|
|
@@ -127,6 +198,33 @@ export function synthesizeFromRecording(
|
|
|
127
198
|
}
|
|
128
199
|
}
|
|
129
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
|
+
|
|
130
228
|
// Bound the recording size fail-closed (defense-in-depth for direct callers;
|
|
131
229
|
// the MCP schema caps this too).
|
|
132
230
|
if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
|
|
@@ -154,9 +252,6 @@ export function synthesizeFromRecording(
|
|
|
154
252
|
if (!scopeRes.ok) return scopeRes
|
|
155
253
|
const scope: ScopeDecision = scopeRes.data
|
|
156
254
|
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
255
|
return {
|
|
161
256
|
ok: false,
|
|
162
257
|
error: {
|
|
@@ -196,17 +291,6 @@ export function synthesizeFromRecording(
|
|
|
196
291
|
|
|
197
292
|
// 6. Interpreter compile (opt-in; fail-closed when the user routed real
|
|
198
293
|
// 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
294
|
const interpreterOpts = opts.interpreter
|
|
211
295
|
|
|
212
296
|
let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
|
|
@@ -220,18 +304,6 @@ export function synthesizeFromRecording(
|
|
|
220
304
|
...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
|
|
221
305
|
}
|
|
222
306
|
|
|
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
307
|
let startingPredicate: PredicateNode | null = null
|
|
236
308
|
const testSeam = interpreterOpts.__testPredicateNode
|
|
237
309
|
if (testSeam !== undefined) {
|
|
@@ -243,11 +315,6 @@ export function synthesizeFromRecording(
|
|
|
243
315
|
try {
|
|
244
316
|
interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
|
|
245
317
|
} 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
318
|
const code = (e as { code?: ToolError['code'] }).code
|
|
252
319
|
const allowedCodes: ToolError['code'][] = [
|
|
253
320
|
'SCOPE_SELF_CALL',
|
|
@@ -338,15 +405,8 @@ export function synthesizeFromRecording(
|
|
|
338
405
|
}
|
|
339
406
|
}
|
|
340
407
|
|
|
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.
|
|
408
|
+
// 6b. Self-verify + minimise.
|
|
346
409
|
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
410
|
return {
|
|
351
411
|
ok: false,
|
|
352
412
|
error: {
|
|
@@ -370,14 +430,12 @@ export function synthesizeFromRecording(
|
|
|
370
430
|
}
|
|
371
431
|
const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
|
|
372
432
|
|
|
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
433
|
const finalPredicate: PredicateNode =
|
|
378
|
-
startingPredicate.op === 'and'
|
|
434
|
+
startingPredicate.op === 'and'
|
|
435
|
+
? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
|
|
436
|
+
: startingPredicate
|
|
379
437
|
|
|
380
|
-
const harnessCases = generateCases(finalPredicate, permitCtx)
|
|
438
|
+
const harnessCases = generateCases(finalPredicate, permitCtx, ORIGINAL_DIMENSIONS)
|
|
381
439
|
const harnessResult = runHarness(finalPredicate, harnessCases)
|
|
382
440
|
if (!harnessResult.ok) {
|
|
383
441
|
return {
|
|
@@ -407,15 +465,13 @@ export function synthesizeFromRecording(
|
|
|
407
465
|
}
|
|
408
466
|
|
|
409
467
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
410
|
-
// canonical bytes back onto the PolicyDocument + PolicyRef.
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
468
|
+
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
469
|
+
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
470
|
+
// breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
|
|
471
|
+
// envelope converts them to a structured `{ok:false, error}`.
|
|
414
472
|
const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
|
|
415
473
|
|
|
416
474
|
if (testSeam !== undefined) {
|
|
417
|
-
// Test seam: build the PolicyDocument + PolicyRef now (compile() was
|
|
418
|
-
// skipped). Shape mirrors the adapter's output exactly.
|
|
419
475
|
interpreterPolicyDocument = {
|
|
420
476
|
grammarVersion: 1,
|
|
421
477
|
installNonce: interpreterOpts.installNonce ?? 1,
|
|
@@ -455,17 +511,14 @@ export function synthesizeFromRecording(
|
|
|
455
511
|
}
|
|
456
512
|
|
|
457
513
|
// 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.
|
|
514
|
+
// interpreter actually lowered are misleading: OZ really did not lower them,
|
|
515
|
+
// but the interpreter did. Drop those so the user-facing warnings reflect
|
|
516
|
+
// what is still UN-enforced, not what OZ alone could not do.
|
|
463
517
|
const ozUncovered = interpreterPolicyRef
|
|
464
518
|
? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
|
|
465
519
|
: compileRes.uncovered
|
|
466
520
|
|
|
467
|
-
// 7. Merge into the OZ-shaped ProposedPolicy.
|
|
468
|
-
// [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
|
|
521
|
+
// 7. Merge into the OZ-shaped ProposedPolicy.
|
|
469
522
|
const ozRefs = compileRes.proposed.policyRefs
|
|
470
523
|
const mergedRefs: ProposedPolicy['policyRefs'] = []
|
|
471
524
|
if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
|
|
@@ -488,12 +541,26 @@ export function synthesizeFromRecording(
|
|
|
488
541
|
policies: mergedRefs,
|
|
489
542
|
}
|
|
490
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
|
+
|
|
491
557
|
const proposed: ProposedPolicy = {
|
|
492
558
|
contextRule: mergedContextRule,
|
|
493
559
|
policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
|
|
494
560
|
policyRefs: mergedRefs,
|
|
495
561
|
parseConfidence: { ...tx.parseConfidence },
|
|
496
562
|
warnings: [
|
|
563
|
+
...zeroPolicyWarning,
|
|
497
564
|
...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
498
565
|
...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
|
|
499
566
|
],
|
|
@@ -502,6 +569,10 @@ export function synthesizeFromRecording(
|
|
|
502
569
|
return { ok: true, data: proposed }
|
|
503
570
|
}
|
|
504
571
|
|
|
572
|
+
// Re-export the throwToolError helper for callers that need to surface a
|
|
573
|
+
// ToolError-shaped throw inside the body (e.g. tests).
|
|
574
|
+
export { throwToolError }
|
|
575
|
+
|
|
505
576
|
/** OZ-side `uncovered` warning patterns that the interpreter adapter
|
|
506
577
|
* actually lowers when wired in. When the interpreter adapter succeeds, we
|
|
507
578
|
* drop matching entries from the OZ uncovered list so the user-facing
|
|
@@ -551,7 +622,7 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
551
622
|
}
|
|
552
623
|
if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
|
|
553
624
|
return synthesisError(
|
|
554
|
-
`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}`
|
|
555
626
|
)
|
|
556
627
|
}
|
|
557
628
|
}
|
|
@@ -562,14 +633,31 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
562
633
|
`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
|
|
563
634
|
)
|
|
564
635
|
}
|
|
636
|
+
// Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
|
|
637
|
+
// C.../56-char shape check so a fixture/LLM-seam marker is reported
|
|
638
|
+
// with the specific placeholder error (not a generic "must be a
|
|
639
|
+
// C... contract" message). A real install routes a C... contract
|
|
640
|
+
// address derivable from the on-chain account; any
|
|
641
|
+
// 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
|
|
642
|
+
// fixture marker that must never reach the install payload.
|
|
643
|
+
if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
|
|
644
|
+
return synthesisError(
|
|
645
|
+
`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
|
|
646
|
+
)
|
|
647
|
+
}
|
|
565
648
|
if (!isContractAddress(sa)) {
|
|
566
649
|
return synthesisError(
|
|
567
650
|
`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
|
|
568
651
|
)
|
|
569
652
|
}
|
|
570
653
|
const nonce = opts.interpreter.installNonce
|
|
571
|
-
|
|
572
|
-
|
|
654
|
+
// Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
|
|
655
|
+
// u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
|
|
656
|
+
// the validUntilLedger SOROBAN_LIMITS.u32Max check above.
|
|
657
|
+
if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
|
|
658
|
+
return synthesisError(
|
|
659
|
+
`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
|
|
660
|
+
)
|
|
573
661
|
}
|
|
574
662
|
const op = opts.interpreter.oracleParams
|
|
575
663
|
if (op) {
|
|
@@ -596,15 +684,32 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
|
|
|
596
684
|
return null
|
|
597
685
|
}
|
|
598
686
|
|
|
687
|
+
/** Placeholder/stub smart-account prefixes (item 5). Mirrors the
|
|
688
|
+
* `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
|
|
689
|
+
* the interpreter-contract strkey; a real install must point at a C...
|
|
690
|
+
* contract address derivable from the on-chain account, never a
|
|
691
|
+
* fixture/LLM-seam marker. */
|
|
692
|
+
const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
|
|
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
|
+
|
|
599
700
|
function isPositiveInt(n: number): boolean {
|
|
600
701
|
return Number.isInteger(n) && n > 0
|
|
601
702
|
}
|
|
602
703
|
|
|
603
|
-
/** True when `s` is a canonical positive decimal integer
|
|
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. */
|
|
604
708
|
function isPositiveI128(s: string): boolean {
|
|
605
709
|
if (!/^[0-9]+$/.test(s)) return false
|
|
606
710
|
try {
|
|
607
|
-
|
|
711
|
+
const v = BigInt(s)
|
|
712
|
+
return v > 0n && v <= I128_MAX
|
|
608
713
|
} catch {
|
|
609
714
|
return false
|
|
610
715
|
}
|
|
@@ -689,19 +794,46 @@ function buildPermitContext(
|
|
|
689
794
|
return ctx
|
|
690
795
|
}
|
|
691
796
|
|
|
692
|
-
function cloneScVal(
|
|
797
|
+
function cloneScVal(
|
|
798
|
+
value: { type: string; value: unknown },
|
|
799
|
+
depth = 0
|
|
800
|
+
): ContractInvocation['args'][number] {
|
|
693
801
|
// EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
|
|
694
802
|
// (decoded by the recorder). We clone top-level shells so the harness can
|
|
695
|
-
// mutate deny cases without aliasing the recorded call.
|
|
803
|
+
// mutate deny cases without aliasing the recorded call. Recursion is
|
|
804
|
+
// bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
|
|
805
|
+
// cannot RangeError the JS stack; the over-depth branch throws a
|
|
806
|
+
// ToolError-shaped error that the `synthesizeFromRecording` envelope
|
|
807
|
+
// (item 3) converts to `{ok:false, error}`.
|
|
696
808
|
if (value.type === 'vec') {
|
|
809
|
+
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
810
|
+
throw cloneDepthError(value)
|
|
811
|
+
}
|
|
697
812
|
return {
|
|
698
813
|
type: 'vec',
|
|
699
|
-
value: (value.value as ContractInvocation['args'][number][]).map(
|
|
814
|
+
value: (value.value as ContractInvocation['args'][number][]).map((v) =>
|
|
815
|
+
cloneScVal(v, depth + 1)
|
|
816
|
+
),
|
|
700
817
|
}
|
|
701
818
|
}
|
|
702
819
|
return { ...value } as ContractInvocation['args'][number]
|
|
703
820
|
}
|
|
704
821
|
|
|
822
|
+
/** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
|
|
823
|
+
* The shape (object with string `code`) is what the `synthesizeFromRecording`
|
|
824
|
+
* envelope detects and converts to a structured `{ok:false, error}`. */
|
|
825
|
+
function cloneDepthError(value: { type: string; value: unknown }): never {
|
|
826
|
+
const err = new Error(
|
|
827
|
+
`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
|
|
828
|
+
) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
|
|
829
|
+
err.code = 'SYNTHESIS_ERROR'
|
|
830
|
+
err.severity = 'error'
|
|
831
|
+
err.retryable = false
|
|
832
|
+
// Tag the offending value so failures are diagnosable.
|
|
833
|
+
err.depthContext = value.type
|
|
834
|
+
throw err
|
|
835
|
+
}
|
|
836
|
+
|
|
705
837
|
/** Walk every `oracle_price` leaf in the predicate and return a price map
|
|
706
838
|
* whose entries satisfy the bound so the intended call permits. The
|
|
707
839
|
* timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
|
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). */
|
|
@@ -33,6 +38,25 @@ export const SOROBAN_LIMITS = {
|
|
|
33
38
|
secondsPerLedger: 5,
|
|
34
39
|
} as const
|
|
35
40
|
|
|
41
|
+
/** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
|
|
42
|
+
* A real Stellar tx caps nesting well below this; the bound hard-caps a
|
|
43
|
+
* hand-crafted payload so the recorder fails CLOSED (records the over-depth
|
|
44
|
+
* branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
|
|
45
|
+
* Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
|
|
46
|
+
* already enforces; lifted to 30 so legitimate nested args (a path vec
|
|
47
|
+
* inside an order vec inside a market-data vec) decode cleanly while a
|
|
48
|
+
* malformed ~10000-deep payload still trips the cap. */
|
|
49
|
+
export const MAX_SCVAL_DEPTH = 30 as const
|
|
50
|
+
|
|
51
|
+
/** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
|
|
52
|
+
* the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
|
|
53
|
+
* so a hand-crafted nested-vec payload cannot RangeError the JS stack during
|
|
54
|
+
* context building. Over-depth throws a ToolError (caught by the
|
|
55
|
+
* `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
|
|
56
|
+
* the caller gets a structured `{ok:false, error}` instead of a thrown
|
|
57
|
+
* RangeError. */
|
|
58
|
+
export const MAX_SCVAL_CLONE_DEPTH = 30 as const
|
|
59
|
+
|
|
36
60
|
/** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
|
|
37
61
|
* AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
|
|
38
62
|
* byte-identical (TS reads the same JSON manifest the Rust build emits). If they
|
|
@@ -222,7 +246,15 @@ export type AmbiguityCode =
|
|
|
222
246
|
|
|
223
247
|
/** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
|
|
224
248
|
* breakdown is the diagnostic the user / agent sees when the gate fires. The
|
|
225
|
-
* 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. */
|
|
226
258
|
export interface ParseConfidence {
|
|
227
259
|
overall: number // 0..1
|
|
228
260
|
knownContracts: string[]
|
|
@@ -232,4 +264,8 @@ export interface ParseConfidence {
|
|
|
232
264
|
}>
|
|
233
265
|
opaqueScVals: Array<{ path: string; type: string }>
|
|
234
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
|
|
235
271
|
}
|
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
|
}
|