@crediolabs/policy-synth 0.1.3 → 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.
- package/README.md +193 -5
- package/dist/adapters/interpreter/adapter.d.ts +38 -0
- package/dist/adapters/interpreter/adapter.js +522 -0
- package/dist/adapters/interpreter/index.d.ts +1 -0
- package/dist/adapters/interpreter/index.js +2 -0
- package/dist/adapters/oz/adapter.js +2 -0
- package/dist/codegen/compile-gate.d.ts +33 -0
- package/dist/codegen/compile-gate.js +119 -0
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/template.d.ts +18 -0
- package/dist/codegen/template.js +131 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ir/types.d.ts +13 -2
- package/dist/predicate/encode.d.ts +10 -0
- package/dist/predicate/encode.js +249 -0
- package/dist/predicate/index.d.ts +1 -0
- package/dist/predicate/index.js +2 -0
- package/dist/review-card/builder.d.ts +14 -0
- package/dist/review-card/builder.js +261 -0
- package/dist/review-card/conflict.d.ts +40 -0
- package/dist/review-card/conflict.js +111 -0
- package/dist/review-card/cross-check.d.ts +11 -0
- package/dist/review-card/cross-check.js +148 -0
- package/dist/review-card/index.d.ts +3 -0
- package/dist/review-card/index.js +4 -0
- package/dist/synth/compose-from-recording.d.ts +35 -7
- package/dist/synth/compose-from-recording.js +225 -34
- package/dist/synth/deny-cases.d.ts +12 -0
- package/dist/synth/deny-cases.js +361 -0
- package/dist/synth/evaluate.d.ts +39 -0
- package/dist/synth/evaluate.js +425 -0
- package/dist/synth/harness.d.ts +16 -0
- package/dist/synth/harness.js +26 -0
- package/dist/synth/index.d.ts +4 -0
- package/dist/synth/index.js +4 -0
- package/dist/synth/minimize.d.ts +4 -0
- package/dist/synth/minimize.js +38 -0
- package/dist/synth/predicate-literals.d.ts +5 -0
- package/dist/synth/predicate-literals.js +25 -0
- package/dist/synth/synthesize-from-recording.d.ts +32 -3
- package/dist/synth/synthesize-from-recording.js +488 -14
- package/dist/types.d.ts +23 -1
- package/dist/verify/envelope.d.ts +15 -0
- package/dist/verify/envelope.js +22 -0
- package/dist/verify/index.d.ts +3 -0
- package/dist/verify/index.js +3 -0
- package/dist/verify/simulate.d.ts +31 -0
- package/dist/verify/simulate.js +242 -0
- package/dist/verify/verify.d.ts +21 -0
- package/dist/verify/verify.js +173 -0
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +642 -0
- package/src/adapters/interpreter/index.ts +8 -0
- package/src/adapters/oz/adapter.ts +2 -0
- package/src/codegen/compile-gate.ts +162 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/template.ts +148 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -0
- package/src/ir/types.ts +9 -2
- package/src/predicate/encode.ts +307 -0
- package/src/predicate/index.ts +3 -0
- package/src/review-card/builder.ts +303 -0
- package/src/review-card/conflict.ts +143 -0
- package/src/review-card/cross-check.ts +158 -0
- package/src/review-card/index.ts +12 -0
- package/src/synth/compose-from-recording.ts +277 -43
- package/src/synth/deny-cases.ts +447 -0
- package/src/synth/evaluate.ts +493 -0
- package/src/synth/harness.ts +40 -0
- package/src/synth/index.ts +12 -0
- package/src/synth/minimize.ts +44 -0
- package/src/synth/predicate-literals.ts +27 -0
- package/src/synth/synthesize-from-recording.ts +572 -15
- package/src/types.ts +19 -2
- package/src/verify/envelope.ts +28 -0
- package/src/verify/index.ts +5 -0
- package/src/verify/simulate.ts +292 -0
- package/src/verify/verify.ts +224 -0
|
@@ -1,25 +1,45 @@
|
|
|
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
|
-
//
|
|
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)` ->
|
|
11
|
+
// 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
|
|
12
|
+
// interpreterIr (predicate-shape), ambiguities, warnings }.
|
|
12
13
|
// 5. `ozAdapter.compile(ir)` -> CompileResult.
|
|
13
|
-
// 6.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
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
44
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
25
45
|
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
@@ -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
|
|
93
|
-
const compileRes =
|
|
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.
|
|
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
|
-
|
|
406
|
+
contextRule: mergedContextRule,
|
|
407
|
+
policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
|
|
408
|
+
policyRefs: mergedRefs,
|
|
109
409
|
parseConfidence: { ...tx.parseConfidence },
|
|
110
410
|
warnings: [
|
|
111
|
-
...
|
|
411
|
+
...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
112
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 {};
|