@crediolabs/policy-synth 0.1.17 → 0.2.0
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 +44 -246
- package/dist/adapters/interpreter/adapter.d.ts +6 -7
- package/dist/adapters/interpreter/adapter.js +28 -48
- package/dist/adapters/oz/adapter.js +12 -13
- package/dist/codegen/compile-gate.js +5 -0
- package/dist/codegen/template.js +17 -0
- package/dist/install/authority-overlap.d.ts +134 -0
- package/dist/install/authority-overlap.js +0 -0
- package/dist/install/build-add-context-rule.d.ts +8 -0
- package/dist/install/build-add-context-rule.js +17 -46
- package/dist/install/build-install-policy.d.ts +12 -5
- package/dist/install/build-install-policy.js +32 -23
- package/dist/install/build-merge-policy.d.ts +70 -0
- package/dist/install/build-merge-policy.js +130 -0
- package/dist/install/get-interpreter-info.js +2 -2
- package/dist/install/index.d.ts +3 -1
- package/dist/install/index.js +8 -1
- package/dist/install/oz-auth.js +5 -3
- package/dist/install/plan-merge-policy.d.ts +49 -0
- package/dist/install/plan-merge-policy.js +86 -0
- package/dist/install/read-account-rules.d.ts +100 -0
- package/dist/install/read-account-rules.js +283 -0
- package/dist/predicate/decode.js +1 -1
- package/dist/predicate/encode.js +171 -10
- package/dist/record/decode.js +5 -6
- package/dist/registry/protocols.d.ts +1 -1
- package/dist/registry/protocols.js +1 -1
- package/dist/review-card/builder.d.ts +6 -0
- package/dist/review-card/builder.js +9 -1
- package/dist/run/index.d.ts +104 -18
- package/dist/run/index.js +359 -75
- package/dist/run/schemas.d.ts +479 -18
- package/dist/run/schemas.js +132 -24
- package/dist/synth/compose-from-recording.d.ts +10 -15
- package/dist/synth/compose-from-recording.js +79 -124
- package/dist/synth/deny-cases.d.ts +5 -0
- package/dist/synth/deny-cases.js +52 -13
- package/dist/synth/evaluate.js +69 -119
- package/dist/synth/harness.d.ts +13 -1
- package/dist/synth/harness.js +22 -1
- package/dist/synth/index.d.ts +1 -1
- package/dist/synth/synthesize-from-recording.d.ts +31 -21
- package/dist/synth/synthesize-from-recording.js +129 -155
- package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
- package/dist-cjs/adapters/interpreter/adapter.js +28 -48
- package/dist-cjs/adapters/oz/adapter.js +12 -13
- package/dist-cjs/codegen/compile-gate.js +5 -0
- package/dist-cjs/codegen/template.js +17 -0
- package/dist-cjs/install/authority-overlap.d.ts +134 -0
- package/dist-cjs/install/authority-overlap.js +0 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
- package/dist-cjs/install/build-add-context-rule.js +17 -45
- package/dist-cjs/install/build-install-policy.d.ts +12 -5
- package/dist-cjs/install/build-install-policy.js +32 -23
- package/dist-cjs/install/build-merge-policy.d.ts +70 -0
- package/dist-cjs/install/build-merge-policy.js +134 -0
- package/dist-cjs/install/get-interpreter-info.js +2 -2
- package/dist-cjs/install/index.d.ts +3 -1
- package/dist-cjs/install/index.js +24 -3
- package/dist-cjs/install/oz-auth.js +5 -3
- package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
- package/dist-cjs/install/plan-merge-policy.js +90 -0
- package/dist-cjs/install/read-account-rules.d.ts +100 -0
- package/dist-cjs/install/read-account-rules.js +296 -0
- package/dist-cjs/predicate/decode.js +1 -1
- package/dist-cjs/predicate/encode.js +171 -10
- package/dist-cjs/record/decode.js +5 -6
- package/dist-cjs/registry/protocols.d.ts +1 -1
- package/dist-cjs/registry/protocols.js +1 -1
- package/dist-cjs/review-card/builder.d.ts +6 -0
- package/dist-cjs/review-card/builder.js +9 -1
- package/dist-cjs/run/index.d.ts +104 -18
- package/dist-cjs/run/index.js +360 -74
- package/dist-cjs/run/schemas.d.ts +479 -18
- package/dist-cjs/run/schemas.js +133 -25
- package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
- package/dist-cjs/synth/compose-from-recording.js +79 -124
- package/dist-cjs/synth/deny-cases.d.ts +5 -0
- package/dist-cjs/synth/deny-cases.js +52 -13
- package/dist-cjs/synth/evaluate.js +69 -119
- package/dist-cjs/synth/harness.d.ts +13 -1
- package/dist-cjs/synth/harness.js +22 -1
- package/dist-cjs/synth/index.d.ts +1 -1
- package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
- package/dist-cjs/synth/synthesize-from-recording.js +129 -153
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +28 -48
- package/src/adapters/oz/adapter.ts +12 -13
- package/src/codegen/compile-gate.ts +5 -0
- package/src/codegen/template.ts +17 -0
- package/src/install/authority-overlap.ts +0 -0
- package/src/install/build-add-context-rule.ts +29 -56
- package/src/install/build-install-policy.ts +41 -23
- package/src/install/build-merge-policy.ts +219 -0
- package/src/install/get-interpreter-info.ts +2 -2
- package/src/install/index.ts +36 -2
- package/src/install/oz-auth.ts +5 -3
- package/src/install/plan-merge-policy.ts +133 -0
- package/src/install/read-account-rules.ts +376 -0
- package/src/predicate/decode.ts +1 -1
- package/src/predicate/encode.ts +176 -10
- package/src/record/decode.ts +5 -6
- package/src/registry/protocols.ts +1 -1
- package/src/review-card/builder.ts +17 -1
- package/src/run/index.ts +500 -102
- package/src/run/schemas.ts +145 -24
- package/src/synth/compose-from-recording.ts +87 -132
- package/src/synth/deny-cases.ts +59 -13
- package/src/synth/evaluate.ts +70 -118
- package/src/synth/harness.ts +29 -1
- package/src/synth/index.ts +1 -0
- package/src/synth/synthesize-from-recording.ts +209 -192
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
|
@@ -20,21 +20,18 @@ export interface InterpreterAdapterOptions {
|
|
|
20
20
|
maxStalenessSeconds?: number;
|
|
21
21
|
maxDeviationBps?: number;
|
|
22
22
|
};
|
|
23
|
-
/** @internal Test-only seam. When set, the orchestrator uses this
|
|
24
|
-
* PredicateNode as the starting point for self-verify + minimise instead
|
|
25
|
-
* of calling the interpreter adapter's compile(). Production callers MUST
|
|
26
|
-
* NOT set this; the field exists so the self-verify pipeline can be
|
|
27
|
-
* exercised on hand-crafted predicates (redundant conjuncts for
|
|
28
|
-
* minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
|
|
29
|
-
* `smartAccountAddress` + `installNonce` + `oracleParams` are still
|
|
30
|
-
* carried through so the emitted PolicyRef + PolicyDocument match the
|
|
31
|
-
* recorded call shape. */
|
|
32
|
-
__testPredicateNode?: PredicateNode;
|
|
33
23
|
}
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
24
|
+
/** PRIVATE test-only extension of `InterpreterAdapterOptions` for the
|
|
25
|
+
* `__testPredicateNode` seam. Exported under a `__` prefix so production
|
|
26
|
+
* callers can grep for it and see it is a test hook. The seam is read in
|
|
27
|
+
* `synthesizeFromRecordingInner` only when `process.env.NODE_ENV === 'test'`
|
|
28
|
+
* - a hard RUNTIME guard throws otherwise so a misconfigured production
|
|
29
|
+
* caller cannot bypass the recording -> interpreter adapter compile path. */
|
|
30
|
+
export type __TestInterpreterAdapterOptions = InterpreterAdapterOptions & {
|
|
31
|
+
/** @internal Test-only seam. Throws at runtime when NODE_ENV !== 'test'. */
|
|
32
|
+
__testPredicateNode?: PredicateNode;
|
|
33
|
+
};
|
|
34
|
+
/** Top-level orchestrator inputs. */
|
|
38
35
|
export interface SynthesizeFromRecordingOptions {
|
|
39
36
|
network: Network;
|
|
40
37
|
userResponses?: ComposeUserResponses;
|
|
@@ -42,13 +39,8 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
42
39
|
threshold: number;
|
|
43
40
|
};
|
|
44
41
|
interpreter?: InterpreterAdapterOptions;
|
|
45
|
-
/** --explain opt-in.
|
|
46
|
-
*
|
|
47
|
-
* to the success envelope so the CLI can render a human-readable
|
|
48
|
-
* review card. Absent or false -> the success envelope is unchanged
|
|
49
|
-
* (byte-identical to today). The flag is ADDITIVE: the existing
|
|
50
|
-
* ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
|
|
51
|
-
* never altered by enabling explain. */
|
|
42
|
+
/** --explain opt-in. The flag is ADDITIVE: existing ProposedPolicy fields are
|
|
43
|
+
* never altered by enabling explain. Absent -> success envelope is byte-identical. */
|
|
52
44
|
explain?: true;
|
|
53
45
|
}
|
|
54
46
|
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
|
|
@@ -71,3 +63,21 @@ export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: S
|
|
|
71
63
|
* failure that the envelope converts to `{ok:false, error}`. */
|
|
72
64
|
declare function throwToolError(code: ToolError['code'], message: string): never;
|
|
73
65
|
export { throwToolError };
|
|
66
|
+
/** Diagnostic reported by `visitOracleLeaves` when an oracle comparison
|
|
67
|
+
* cannot be normalised to oracle-on-left. Currently only "oracle-on-right
|
|
68
|
+
* with a non-literal LHS" - the other branches already produce a parseable
|
|
69
|
+
* threshold and are returned to the caller. */
|
|
70
|
+
export interface OracleNormalisationWarning {
|
|
71
|
+
/** The dimension name (currently always "oracle_normalisation_dropped"). */
|
|
72
|
+
dimension: 'oracle_normalisation_dropped';
|
|
73
|
+
/** The operator as written. */
|
|
74
|
+
op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte';
|
|
75
|
+
/** The asset the oracle leaf is bound to. */
|
|
76
|
+
asset: string;
|
|
77
|
+
/** The non-literal RHS (oracle-on-left) or LHS (oracle-on-right) leaf
|
|
78
|
+
* whose shape stopped the normalisation. Surfaced for diagnostics;
|
|
79
|
+
* intentionally a partial view - the full leaf is the caller's job. */
|
|
80
|
+
otherKind: string;
|
|
81
|
+
/** Human-readable message (matches the warn-on-drop text). */
|
|
82
|
+
message: string;
|
|
83
|
+
}
|
|
@@ -1,35 +1,10 @@
|
|
|
1
1
|
// src/synth/synthesize-from-recording.ts - recording-path orchestrator.
|
|
2
2
|
//
|
|
3
|
-
// `synthesizeFromRecording`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the caller may relax via `confidenceOverride.threshold`).
|
|
9
|
-
// 2. `lower(tx)` -> IntentFacts.
|
|
10
|
-
// 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
|
|
11
|
-
// 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
|
|
12
|
-
// interpreterIr (predicate-shape), ambiguities, warnings }.
|
|
13
|
-
// 5. `ozAdapter.compile(ir)` -> CompileResult.
|
|
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.
|
|
30
|
-
//
|
|
31
|
-
// Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
|
|
32
|
-
// No randomness, no clock, no globals.
|
|
3
|
+
// `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
|
|
4
|
+
// via the same `PolicyIR` + adapter pair used by the deterministic Mandate path.
|
|
5
|
+
// Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
|
|
6
|
+
// -> OZ compile -> (opt-in) interpreter compile + self-verify + minimise -> merge.
|
|
7
|
+
// Same (tx, opts, ozConfig) -> byte-identical ProposedPolicy (no randomness, clock, globals).
|
|
33
8
|
import { createInterpreterAdapter, lowerRuleToPredicate, PLACEHOLDER_INTERPRETER_ADDRESS, } from "../adapters/interpreter/adapter.js";
|
|
34
9
|
import { createOzAdapter } from "../adapters/oz/adapter.js";
|
|
35
10
|
import { encodePredicate } from "../predicate/encode.js";
|
|
@@ -43,13 +18,11 @@ import { minimize } from "./minimize.js";
|
|
|
43
18
|
import { decideScope } from "./scope.js";
|
|
44
19
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
45
20
|
export function synthesizeFromRecording(tx, opts, ozConfig) {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
|
|
52
|
-
// a structured ToolError rather than a thrown RangeError.
|
|
21
|
+
// Convert any ToolError-shaped throw (object with a string `.code`) to a
|
|
22
|
+
// structured `{ok:false, error}`; anything else is rethrown so genuine bugs
|
|
23
|
+
// crash instead of being silently swallowed. Wraps the entire body so
|
|
24
|
+
// cap errors (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) and cloneScVal depth
|
|
25
|
+
// throws surface as structured ToolErrors rather than RangeErrors.
|
|
53
26
|
try {
|
|
54
27
|
return synthesizeFromRecordingInner(tx, opts, ozConfig);
|
|
55
28
|
}
|
|
@@ -68,10 +41,9 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
|
|
|
68
41
|
throw e;
|
|
69
42
|
}
|
|
70
43
|
}
|
|
71
|
-
/** True when `e` is
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* the body's internal helpers agree on. */
|
|
44
|
+
/** True when `e` is a ToolError-shaped throw (object with a string `code`).
|
|
45
|
+
* Detected by string `code` field — the only contract the body's internal
|
|
46
|
+
* helpers agree on. */
|
|
75
47
|
function isToolErrorShape(e) {
|
|
76
48
|
if (e === null || typeof e !== 'object')
|
|
77
49
|
return false;
|
|
@@ -88,17 +60,14 @@ function throwToolError(code, message) {
|
|
|
88
60
|
err.retryable = false;
|
|
89
61
|
throw err;
|
|
90
62
|
}
|
|
91
|
-
/** Synthesize a ProposedPolicy from a recorded transaction. */
|
|
92
63
|
function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
93
64
|
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
94
65
|
const invalid = validateOptions(opts);
|
|
95
66
|
if (invalid)
|
|
96
67
|
return { ok: false, error: invalid };
|
|
97
|
-
// 0a. per-movement amount validation
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// one - reject it here so the downstream `BigInt(m.amount)` in
|
|
101
|
-
// `buildPermitContext` cannot throw a SyntaxError past the envelope.
|
|
68
|
+
// 0a. per-movement amount validation. Reject malformed recordings here so
|
|
69
|
+
// the downstream `BigInt(m.amount)` in `buildPermitContext` cannot throw
|
|
70
|
+
// a SyntaxError past the envelope.
|
|
102
71
|
for (const [i, m] of tx.tokenMovements.entries()) {
|
|
103
72
|
if (!/^[0-9]+$/.test(m.amount)) {
|
|
104
73
|
return {
|
|
@@ -126,15 +95,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
126
95
|
},
|
|
127
96
|
};
|
|
128
97
|
}
|
|
129
|
-
// 1a. Zero-invocation refusal
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// policy must scope to an authorized contract call. Refuse before any
|
|
133
|
-
// lower/scope work so the failure is specific and actionable. The
|
|
134
|
-
// recorder's silence is also made visible via `parseConfidence.noInvocations`
|
|
135
|
-
// so consumers can pattern-match without inferring from `invocations: []`
|
|
136
|
-
// next to `overall: 1.0`. The message does NOT ask for an ABI - the
|
|
137
|
-
// failure mode is the recording shape, not decoding coverage.
|
|
98
|
+
// 1a. Zero-invocation refusal. A recording with zero invocations clears
|
|
99
|
+
// parseConfidence legitimately (denom === 0 short-circuits overall to 1.0),
|
|
100
|
+
// but a policy must scope to an authorized contract call. Refuse early.
|
|
138
101
|
const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
|
|
139
102
|
if (hasNoInvocations) {
|
|
140
103
|
return {
|
|
@@ -164,9 +127,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
164
127
|
},
|
|
165
128
|
};
|
|
166
129
|
}
|
|
167
|
-
// 2. lower.
|
|
168
130
|
const facts = lower(tx);
|
|
169
|
-
// 3. decideScope.
|
|
170
131
|
const scopeRes = decideScope(facts, {
|
|
171
132
|
network: opts.network,
|
|
172
133
|
...(opts.userResponses?.validUntilLedger !== undefined
|
|
@@ -196,11 +157,8 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
196
157
|
};
|
|
197
158
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
|
|
198
159
|
// --explain hook: capture the in-memory predicate tree + the real
|
|
199
|
-
// self-verify verdict
|
|
200
|
-
//
|
|
201
|
-
// already gates the synthesis (it is not a parallel simulation); the
|
|
202
|
-
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
203
|
-
// after the gate, so the explain hook reuses them - no extra work.
|
|
160
|
+
// self-verify verdict (built from the SAME runHarness + evaluate that gated
|
|
161
|
+
// the synthesis, not a parallel simulation).
|
|
204
162
|
let explain = null;
|
|
205
163
|
let explainSim = null;
|
|
206
164
|
// 5. OZ compile (always runs).
|
|
@@ -223,6 +181,10 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
223
181
|
const interpreterOpts = opts.interpreter;
|
|
224
182
|
let interpreterPolicyDocument = null;
|
|
225
183
|
let interpreterPolicyRef = null;
|
|
184
|
+
// Cross-layer L3: declared OUTSIDE the `if (interpreterOpts)` block so the
|
|
185
|
+
// warnings folded into `proposed.warnings[]` (which lives after that block)
|
|
186
|
+
// can read it. The block assigns it; the default is empty.
|
|
187
|
+
let permitCtxWarnings = [];
|
|
226
188
|
if (interpreterOpts) {
|
|
227
189
|
const interpreterConfig = {
|
|
228
190
|
network: opts.network,
|
|
@@ -231,8 +193,17 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
231
193
|
...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
|
|
232
194
|
};
|
|
233
195
|
let startingPredicate = null;
|
|
196
|
+
// `__testPredicateNode` is a test-only seam. It is NOT in the public
|
|
197
|
+
// `InterpreterAdapterOptions` type, so a production caller cannot set it
|
|
198
|
+
// without bypassing the type system. We read it via a private cast and
|
|
199
|
+
// enforce a runtime NODE_ENV check so a misconfigured production caller
|
|
200
|
+
// that smuggles it in (any-cast, JSON-driven opt, etc.) is caught here
|
|
201
|
+
// rather than silently overriding the compiled predicate.
|
|
234
202
|
const testSeam = interpreterOpts.__testPredicateNode;
|
|
235
203
|
if (testSeam !== undefined) {
|
|
204
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
205
|
+
throw new Error('synthesizeFromRecording: __testPredicateNode is a test-only seam and is refused outside NODE_ENV=test');
|
|
206
|
+
}
|
|
236
207
|
startingPredicate = testSeam;
|
|
237
208
|
}
|
|
238
209
|
else {
|
|
@@ -349,7 +320,12 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
349
320
|
},
|
|
350
321
|
};
|
|
351
322
|
}
|
|
352
|
-
|
|
323
|
+
// Cross-layer L3: warnings collected from `buildPermitContext` (currently
|
|
324
|
+
// only the oracle-on-right normaliser) and folded into the proposed
|
|
325
|
+
// policy's `warnings[]` so the caller sees them on the success envelope.
|
|
326
|
+
const permitCtxResult = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
|
|
327
|
+
const permitCtx = permitCtxResult.ctx;
|
|
328
|
+
permitCtxWarnings = permitCtxResult.warnings;
|
|
353
329
|
const finalPredicate = startingPredicate.op === 'and'
|
|
354
330
|
? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
|
|
355
331
|
: startingPredicate;
|
|
@@ -380,12 +356,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
380
356
|
},
|
|
381
357
|
};
|
|
382
358
|
}
|
|
383
|
-
// --explain capture: the
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
387
|
-
// each deny case to surface its concrete reason; the harness only
|
|
388
|
-
// records whether the got-matches-expected boundary held.
|
|
359
|
+
// --explain capture: quote the SAME verdict that gated the synthesis.
|
|
360
|
+
// Re-evaluate each deny case to surface its concrete reason (the harness
|
|
361
|
+
// only records whether the got-matches-expected boundary held).
|
|
389
362
|
if (opts.explain) {
|
|
390
363
|
explain = finalPredicate;
|
|
391
364
|
const evaluatedCases = [
|
|
@@ -411,10 +384,9 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
411
384
|
};
|
|
412
385
|
}
|
|
413
386
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
414
|
-
// canonical bytes back onto the PolicyDocument + PolicyRef.
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// envelope converts them to a structured `{ok:false, error}`.
|
|
387
|
+
// canonical bytes back onto the PolicyDocument + PolicyRef. Cap breaches
|
|
388
|
+
// (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES) throw ToolError-shaped errors;
|
|
389
|
+
// the outer envelope converts them to structured `{ok:false, error}`.
|
|
418
390
|
const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
|
|
419
391
|
if (testSeam !== undefined) {
|
|
420
392
|
interpreterPolicyDocument = {
|
|
@@ -487,9 +459,7 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
487
459
|
// When nothing installable was synthesised (no interpreter doc AND no OZ
|
|
488
460
|
// policy refs), an empty `policies` array reads as "no restrictions" rather
|
|
489
461
|
// than "I synthesised nothing". Surface that explicitly so the empty result
|
|
490
|
-
// is never mistaken for a permissive policy
|
|
491
|
-
// but it constrains nothing. (Kept as `{ok:true}` so the documented
|
|
492
|
-
// Path-A/Path-B demo behaviour is preserved - see F3.)
|
|
462
|
+
// is never mistaken for a permissive policy.
|
|
493
463
|
const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
|
|
494
464
|
? [
|
|
495
465
|
'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.',
|
|
@@ -504,16 +474,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
504
474
|
...zeroPolicyWarning,
|
|
505
475
|
...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
506
476
|
...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
|
|
477
|
+
...permitCtxWarnings,
|
|
507
478
|
],
|
|
508
479
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
509
480
|
};
|
|
510
|
-
// --explain success envelope.
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// is
|
|
514
|
-
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
515
|
-
// interpreter was never engaged, so permit is deny with a truthful
|
|
516
|
-
// reason and evaluatedCases is empty.
|
|
481
|
+
// --explain success envelope. When opts.explain is set and the OZ-only
|
|
482
|
+
// path ran, construct the minimal honest SimulationResult: the verdict
|
|
483
|
+
// is NOT a passing simulation - the interpreter was never engaged, so
|
|
484
|
+
// permit is deny with a truthful reason and evaluatedCases is empty.
|
|
517
485
|
const envelope = { ok: true, data: proposed };
|
|
518
486
|
if (opts.explain) {
|
|
519
487
|
if (explainSim) {
|
|
@@ -536,24 +504,21 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
536
504
|
}
|
|
537
505
|
return envelope;
|
|
538
506
|
}
|
|
539
|
-
// Re-export the throwToolError helper for callers that need to surface a
|
|
540
|
-
// ToolError-shaped throw inside the body (e.g. tests).
|
|
541
507
|
export { throwToolError };
|
|
542
|
-
/** OZ-side `uncovered` warning patterns
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* `describeSelector`). */
|
|
508
|
+
/** OZ-side `uncovered` warning patterns the interpreter adapter actually lowers
|
|
509
|
+
* when wired in. When the interpreter adapter succeeds, matching entries are
|
|
510
|
+
* dropped from the OZ uncovered list so user-facing warnings reflect what is
|
|
511
|
+
* still UN-enforced rather than what OZ alone could not do. Matches the
|
|
512
|
+
* exact descriptor strings the OZ adapter emits (see `src/adapters/oz/adapter.ts`
|
|
513
|
+
* `describeCondition` / `describeSelector`). */
|
|
549
514
|
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/;
|
|
550
515
|
/** Reject non-sane inputs before any policy is synthesized. windowSeconds /
|
|
551
516
|
* validUntilLedger / invocationLimit must be positive integers; limitAmount a
|
|
552
|
-
* positive i128 decimal string; network mainnet|testnet. When the
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
517
|
+
* positive i128 decimal string; network mainnet|testnet. When the interpreter
|
|
518
|
+
* adapter is opted in, `smartAccountAddress` must be a C... contract address
|
|
519
|
+
* (the on-chain policy-bound account, NOT the G... source account),
|
|
520
|
+
* `installNonce` must fit u32 (default 1), and `oracleParams` must
|
|
521
|
+
* tighten-only vs the wasm defaults. */
|
|
557
522
|
function validateOptions(opts) {
|
|
558
523
|
if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
|
|
559
524
|
return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
|
|
@@ -585,13 +550,9 @@ function validateOptions(opts) {
|
|
|
585
550
|
if (typeof sa !== 'string' || sa.length === 0) {
|
|
586
551
|
return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
|
|
587
552
|
}
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// C... contract" message). A real install routes a C... contract
|
|
592
|
-
// address derivable from the on-chain account; any
|
|
593
|
-
// 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
|
|
594
|
-
// fixture marker that must never reach the install payload.
|
|
553
|
+
// Block placeholder/stub prefixes BEFORE the C.../56-char shape check so
|
|
554
|
+
// a fixture/LLM-seam marker is reported with the specific placeholder
|
|
555
|
+
// error. 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' must never reach install.
|
|
595
556
|
if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
|
|
596
557
|
return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
|
|
597
558
|
}
|
|
@@ -599,9 +560,7 @@ function validateOptions(opts) {
|
|
|
599
560
|
return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
|
|
600
561
|
}
|
|
601
562
|
const nonce = opts.interpreter.installNonce;
|
|
602
|
-
//
|
|
603
|
-
// u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
|
|
604
|
-
// the validUntilLedger SOROBAN_LIMITS.u32Max check above.
|
|
563
|
+
// installNonce must fit u32 (the on-chain per-rule nonce is a u32).
|
|
605
564
|
if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
|
|
606
565
|
return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
|
|
607
566
|
}
|
|
@@ -621,7 +580,7 @@ function validateOptions(opts) {
|
|
|
621
580
|
}
|
|
622
581
|
return null;
|
|
623
582
|
}
|
|
624
|
-
/** Placeholder/stub smart-account prefixes
|
|
583
|
+
/** Placeholder/stub smart-account prefixes. Mirrors the
|
|
625
584
|
* `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
|
|
626
585
|
* the interpreter-contract strkey; a real install must point at a C...
|
|
627
586
|
* contract address derivable from the on-chain account, never a
|
|
@@ -629,16 +588,15 @@ function validateOptions(opts) {
|
|
|
629
588
|
const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
|
|
630
589
|
/** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
|
|
631
590
|
* cannot be represented on-chain, so reject it at the synthesis boundary
|
|
632
|
-
* (fail-closed) instead of passing it through as an over-broad spending_limit.
|
|
633
|
-
* Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
|
|
591
|
+
* (fail-closed) instead of passing it through as an over-broad spending_limit. */
|
|
634
592
|
const I128_MAX = 2n ** 127n - 1n;
|
|
635
593
|
function isPositiveInt(n) {
|
|
636
594
|
return Number.isInteger(n) && n > 0;
|
|
637
595
|
}
|
|
638
|
-
/** True when `s` is a canonical positive decimal integer inside
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
596
|
+
/** True when `s` is a canonical positive decimal integer inside [1, 2^127-1].
|
|
597
|
+
* Values above the i128 ceiling are rejected fail-closed: they cannot be
|
|
598
|
+
* installed on-chain, and accepting them would emit a spending_limit with an
|
|
599
|
+
* effectively unbounded cap. */
|
|
642
600
|
function isPositiveI128(s) {
|
|
643
601
|
if (!/^[0-9]+$/.test(s))
|
|
644
602
|
return false;
|
|
@@ -672,20 +630,17 @@ function mergeAmbiguities(...lists) {
|
|
|
672
630
|
}
|
|
673
631
|
return out;
|
|
674
632
|
}
|
|
675
|
-
/** Build the permit `EvalContext` the self-verify harness drives.
|
|
676
|
-
* mirrors the intended recorded call
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* permits the recorded call it was derived from); a failure surfaces
|
|
680
|
-
* as DENY_CASE_FAILURE.
|
|
633
|
+
/** Build the permit `EvalContext` the self-verify harness drives. Shape
|
|
634
|
+
* mirrors the intended recorded call so:
|
|
635
|
+
* - `evaluate(predicate, ctx).permit === true` must hold (a failure
|
|
636
|
+
* surfaces as DENY_CASE_FAILURE).
|
|
681
637
|
* - `generateCases(predicate, ctx)` produces a deny battery that reflects
|
|
682
|
-
* the actual recorded move (real amount,
|
|
683
|
-
* Amounts are summed per-token over all movements
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
* exercise the ORACLE_* deny paths. */
|
|
638
|
+
* the actual recorded move (real amount, args, window start).
|
|
639
|
+
* Amounts are summed per-token over all movements (BigInt, never lossy).
|
|
640
|
+
* `oraclePriceByAsset` contains a price+timestamp satisfying each
|
|
641
|
+
* `oracle_price` leaf so the intended call permits under every bound;
|
|
642
|
+
* the harness mutates those entries (stale / missing / deviation / paused)
|
|
643
|
+
* to exercise the ORACLE_* deny paths. */
|
|
689
644
|
function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
|
|
690
645
|
const amountByToken = {};
|
|
691
646
|
const totals = new Map();
|
|
@@ -696,7 +651,8 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
|
|
|
696
651
|
for (const [token, total] of totals) {
|
|
697
652
|
amountByToken[token] = total.toString();
|
|
698
653
|
}
|
|
699
|
-
const
|
|
654
|
+
const warnings = [];
|
|
655
|
+
const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt, warnings);
|
|
700
656
|
const ctx = {
|
|
701
657
|
contract: scope.contract,
|
|
702
658
|
fn: topLevel.fn,
|
|
@@ -711,16 +667,14 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
|
|
|
711
667
|
if (userResponses?.validUntilLedger !== undefined) {
|
|
712
668
|
ctx.validUntilLedger = userResponses.validUntilLedger;
|
|
713
669
|
}
|
|
714
|
-
return ctx;
|
|
670
|
+
return { ctx, warnings };
|
|
715
671
|
}
|
|
716
672
|
function cloneScVal(value, depth = 0) {
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
// ToolError-shaped error that the `synthesizeFromRecording` envelope
|
|
723
|
-
// (item 3) converts to `{ok:false, error}`.
|
|
673
|
+
// Clone top-level shells so the harness can mutate deny cases without
|
|
674
|
+
// aliasing the recorded call. Recursion bounded by MAX_SCVAL_CLONE_DEPTH so
|
|
675
|
+
// a hand-crafted nested-vec cannot RangeError the JS stack; the over-depth
|
|
676
|
+
// branch throws a ToolError-shaped error the envelope converts to
|
|
677
|
+
// `{ok:false, error}`.
|
|
724
678
|
if (value.type === 'vec') {
|
|
725
679
|
if (depth >= MAX_SCVAL_CLONE_DEPTH) {
|
|
726
680
|
throw cloneDepthError(value);
|
|
@@ -732,25 +686,19 @@ function cloneScVal(value, depth = 0) {
|
|
|
732
686
|
}
|
|
733
687
|
return { ...value };
|
|
734
688
|
}
|
|
735
|
-
/** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
|
|
736
|
-
* The shape (object with string `code`) is what the `synthesizeFromRecording`
|
|
737
|
-
* envelope detects and converts to a structured `{ok:false, error}`. */
|
|
738
689
|
function cloneDepthError(value) {
|
|
739
690
|
const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
|
|
740
691
|
err.code = 'SYNTHESIS_ERROR';
|
|
741
692
|
err.severity = 'error';
|
|
742
693
|
err.retryable = false;
|
|
743
|
-
// Tag the offending value so failures are diagnosable.
|
|
744
694
|
err.depthContext = value.type;
|
|
745
695
|
throw err;
|
|
746
696
|
}
|
|
747
|
-
/** Walk every `oracle_price` leaf
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
|
|
752
|
-
* Stellar. */
|
|
753
|
-
function oracleSatisfyingPrices(predicate, nowSeconds) {
|
|
697
|
+
/** Walk every `oracle_price` leaf and return a price map whose entries satisfy
|
|
698
|
+
* the bound. Timestamp pinned to `nowSeconds` (the recorded `fetchedAt`) so
|
|
699
|
+
* the fresh-oracle deny case in `generateCases` is the only path that flips
|
|
700
|
+
* this map. Negatives clamped at 0 - oracle prices are non-negative on Stellar. */
|
|
701
|
+
function oracleSatisfyingPrices(predicate, nowSeconds, warnings) {
|
|
754
702
|
const out = {};
|
|
755
703
|
visitOracleLeaves(predicate, (asset, op, bound) => {
|
|
756
704
|
let price;
|
|
@@ -768,18 +716,25 @@ function oracleSatisfyingPrices(predicate, nowSeconds) {
|
|
|
768
716
|
if (price < 0n)
|
|
769
717
|
price = 0n;
|
|
770
718
|
out[asset] = { price: price.toString(), timestampSeconds: nowSeconds };
|
|
719
|
+
}, (warning) => {
|
|
720
|
+
// Cross-layer L3: oracle-on-right that cannot be normalised is
|
|
721
|
+
// surfaced as a warning, not silently dropped. The Rust interpreter
|
|
722
|
+
// would surface this as `UnsupportedNode`; the warning lets the
|
|
723
|
+
// caller decide whether to fix the predicate shape or accept the
|
|
724
|
+
// over-permissive hole.
|
|
725
|
+
warnings.push(warning.message);
|
|
771
726
|
});
|
|
772
727
|
return out;
|
|
773
728
|
}
|
|
774
|
-
function visitOracleLeaves(node, visit) {
|
|
729
|
+
function visitOracleLeaves(node, visit, onWarning) {
|
|
775
730
|
switch (node.op) {
|
|
776
731
|
case 'and':
|
|
777
732
|
case 'or':
|
|
778
733
|
for (const child of node.children)
|
|
779
|
-
visitOracleLeaves(child, visit);
|
|
734
|
+
visitOracleLeaves(child, visit, onWarning);
|
|
780
735
|
return;
|
|
781
736
|
case 'not':
|
|
782
|
-
visitOracleLeaves(node.child, visit);
|
|
737
|
+
visitOracleLeaves(node.child, visit, onWarning);
|
|
783
738
|
return;
|
|
784
739
|
case 'eq':
|
|
785
740
|
case 'lt':
|
|
@@ -800,8 +755,27 @@ function visitOracleLeaves(node, visit) {
|
|
|
800
755
|
oracleAsset = rightLeaf.asset;
|
|
801
756
|
literal = oracleLiteralFromLeaf(leftLeaf);
|
|
802
757
|
}
|
|
803
|
-
if (oracleAsset === undefined || literal === undefined)
|
|
758
|
+
if (oracleAsset === undefined || literal === undefined) {
|
|
759
|
+
// Two reasons the case cannot be normalised:
|
|
760
|
+
// - neither side is an oracle leaf (visitor has nothing to do)
|
|
761
|
+
// - oracle-on-right with a non-literal LHS (Rust dispatch would
|
|
762
|
+
// hit UnsupportedNode; the TS model cannot build a permit ctx
|
|
763
|
+
// for it)
|
|
764
|
+
// Only the second is a meaningful warning; the first is a no-op
|
|
765
|
+
// (the visitor was called for a non-oracle comparison). Emit the
|
|
766
|
+
// warning when an oracle IS present on one side but the other
|
|
767
|
+
// side is not a parseable threshold literal.
|
|
768
|
+
if (rightIsOracle) {
|
|
769
|
+
onWarning?.({
|
|
770
|
+
dimension: 'oracle_normalisation_dropped',
|
|
771
|
+
op: node.op,
|
|
772
|
+
asset: rightLeaf.kind === 'oracle_price' ? rightLeaf.asset : '',
|
|
773
|
+
otherKind: leftLeaf.kind,
|
|
774
|
+
message: `oracle-on-right cannot be normalised: the LHS (kind=${leftLeaf.kind}) is not an oracle_threshold literal; the Rust interpreter would surface UnsupportedNode here`,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
804
777
|
return;
|
|
778
|
+
}
|
|
805
779
|
visit(oracleAsset, node.op, literal);
|
|
806
780
|
return;
|
|
807
781
|
}
|
|
@@ -811,9 +785,9 @@ function visitOracleLeaves(node, visit) {
|
|
|
811
785
|
return;
|
|
812
786
|
}
|
|
813
787
|
}
|
|
814
|
-
/**
|
|
815
|
-
* Thresholds carry their own basis, so reading the digits raw would build
|
|
816
|
-
* permit context off by 10^(decimals-9) and the intended call would not
|
|
788
|
+
/** Restate an oracle threshold on the normalised 9-dp basis prices use.
|
|
789
|
+
* Thresholds carry their own basis, so reading the digits raw would build
|
|
790
|
+
* a permit context off by 10^(decimals-9) and the intended call would not
|
|
817
791
|
* satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
|
|
818
792
|
function oracleLiteralFromLeaf(leaf) {
|
|
819
793
|
if (leaf.kind !== 'oracle_threshold')
|
|
@@ -4,7 +4,7 @@ import type { Network, PredicateNode } from '../../types.ts';
|
|
|
4
4
|
/** [VERIFY] NOT a real deployed address. The interpreter is a per-network
|
|
5
5
|
* deploy artifact we do not have yet; install is a later phase. */
|
|
6
6
|
export declare const PLACEHOLDER_INTERPRETER_ADDRESS = "VERIFY-interpreter-address";
|
|
7
|
-
/** Wasm-level oracle defaults (mirrors
|
|
7
|
+
/** Wasm-level oracle defaults (mirrors the interpreter's `OracleParams`).
|
|
8
8
|
* Per-policy overrides may TIGHTEN only - they may never exceed these. */
|
|
9
9
|
export declare const ORACLE_DEFAULTS: {
|
|
10
10
|
readonly maxStalenessSeconds: 600;
|
|
@@ -29,10 +29,9 @@ export interface InterpreterAdapterConfig {
|
|
|
29
29
|
export declare function createInterpreterAdapter(config: InterpreterAdapterConfig): CustodyAdapter;
|
|
30
30
|
/** Lower a single IR rule to the canonical pre-encoding `PredicateNode`. The
|
|
31
31
|
* orchestrator uses this to wire the self-verify + minimise pipeline: after
|
|
32
|
-
* `compile(ir)` succeeds
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
37
|
-
* here; callers that need it should use the result of `compile(ir)`. */
|
|
32
|
+
* `compile(ir)` succeeds it re-derives the PredicateNode via this helper to
|
|
33
|
+
* drive `minimize` and `runHarness` on the SAME shape the encoder saw. Pure
|
|
34
|
+
* and deterministic: same `rule + config` -> byte-identical PredicateNode.
|
|
35
|
+
* The `uncovered` list is NOT re-derived - callers needing it must use
|
|
36
|
+
* `compile(ir)`. */
|
|
38
37
|
export declare function lowerRuleToPredicate(rule: IRPolicyRule, config: InterpreterAdapterConfig): PredicateNode;
|