@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
|
@@ -3,6 +3,11 @@ import type { EvalContext } from './evaluate.ts';
|
|
|
3
3
|
export interface DenyCase {
|
|
4
4
|
dimension: string;
|
|
5
5
|
ctx: EvalContext;
|
|
6
|
+
/** Canonical reason the Rust interpreter returns for this dimension.
|
|
7
|
+
* Optional: when set, `runHarness` asserts the TS evaluator emits this
|
|
8
|
+
* exact string, so a future TS/Rust reason divergence fails CI. When
|
|
9
|
+
* absent the case is only checked on the boolean decision. */
|
|
10
|
+
expectedReason?: string;
|
|
6
11
|
}
|
|
7
12
|
export interface GeneratedCases {
|
|
8
13
|
permit: EvalContext;
|
package/dist/synth/deny-cases.js
CHANGED
|
@@ -6,16 +6,29 @@ const ORACLE_CASES = [
|
|
|
6
6
|
['oracle_deviation_exceeded', 'deviation'],
|
|
7
7
|
['oracle_paused', 'paused'],
|
|
8
8
|
];
|
|
9
|
+
// Cross-layer reason contract: the TS evaluator's deny reason for each
|
|
10
|
+
// oracle error category must match the Rust interpreter's `DenyReason` code
|
|
11
|
+
// string (the same strings surface in the conformance fixture). The harness
|
|
12
|
+
// asserts them via the per-case `expectedReason`; a future divergence
|
|
13
|
+
// surfaces as a REASON_MISMATCH failure.
|
|
14
|
+
const ORACLE_ERROR_REASON = {
|
|
15
|
+
stale: 'ORACLE_STALE',
|
|
16
|
+
missing: 'ORACLE_MISSING',
|
|
17
|
+
deviation: 'ORACLE_DEVIATION_EXCEEDED',
|
|
18
|
+
paused: 'ORACLE_PAUSED',
|
|
19
|
+
};
|
|
9
20
|
// Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
|
|
10
21
|
const ADJACENT_ASSETS = [
|
|
11
22
|
'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
|
|
12
23
|
'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
|
|
13
24
|
];
|
|
14
|
-
//
|
|
25
|
+
// Property-harness mutation dimensions excluded from the synth pipeline's
|
|
15
26
|
// self-verify call so existing fixtures still emit policies. The harness tests
|
|
16
27
|
// them as FINDINGS against the already-emitted policy.
|
|
17
28
|
const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
|
|
18
|
-
// The
|
|
29
|
+
// The dimensions the synth pipeline uses for self-verify and minimise. No count
|
|
30
|
+
// is written here on purpose: this comment said 15 while the array held 17, and
|
|
31
|
+
// anyone checking the number should read the array rather than the prose.
|
|
19
32
|
// Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
|
|
20
33
|
// alongside the existing dimensions so the per-element binds emitted for
|
|
21
34
|
// Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
|
|
@@ -61,7 +74,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
61
74
|
continue;
|
|
62
75
|
const mutated = mutateBigIntRecord(permitCtx, 'amountByToken', comparison.left.token, comparison);
|
|
63
76
|
if (mutated)
|
|
64
|
-
denies.push({ dimension: 'amount', ctx: mutated });
|
|
77
|
+
denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
|
|
65
78
|
}
|
|
66
79
|
const movedTokens = new Set();
|
|
67
80
|
for (const comparison of facts.comparisons) {
|
|
@@ -70,6 +83,15 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
for (const token of movedTokens) {
|
|
86
|
+
// The `asset` deny case mutates BOTH the amount record AND the contract
|
|
87
|
+
// binding (token's value moved to an adjacent asset; the same swap applied
|
|
88
|
+
// to any address-typed `call_arg` / `call_contract` literal). The actual
|
|
89
|
+
// deny reason is therefore predicate-dependent - the order of evaluation
|
|
90
|
+
// is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
|
|
91
|
+
// that has both bindings denies with CONTRACT_SCOPE, while a policy with
|
|
92
|
+
// only an amount binding denies with AMOUNT_BOUND. The reason assertion
|
|
93
|
+
// would be brittle here; the boolean decision is what we strictly need
|
|
94
|
+
// to pin. Leave `expectedReason` unset for this dimension.
|
|
73
95
|
denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) });
|
|
74
96
|
}
|
|
75
97
|
const contractConstraints = [
|
|
@@ -81,7 +103,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
81
103
|
for (const _constraint of contractConstraints) {
|
|
82
104
|
const ctx = cloneContext(permitCtx);
|
|
83
105
|
ctx.contract = distinctText(permitCtx.contract, 'contract');
|
|
84
|
-
denies.push({ dimension: 'contract', ctx });
|
|
106
|
+
denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' });
|
|
85
107
|
}
|
|
86
108
|
const functionConstraints = [
|
|
87
109
|
...facts.comparisons.filter((node) => node.op === 'eq' && node.left.kind === 'call_fn' && node.right.kind === 'literal_symbol'),
|
|
@@ -90,19 +112,19 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
90
112
|
for (const _constraint of functionConstraints) {
|
|
91
113
|
const ctx = cloneContext(permitCtx);
|
|
92
114
|
ctx.fn = distinctText(permitCtx.fn, 'function');
|
|
93
|
-
denies.push({ dimension: 'function', ctx });
|
|
115
|
+
denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' });
|
|
94
116
|
}
|
|
95
117
|
if (permitCtx.validUntilLedger !== undefined) {
|
|
96
118
|
const ctx = cloneContext(permitCtx);
|
|
97
119
|
ctx.atLedger = permitCtx.validUntilLedger + 1;
|
|
98
|
-
denies.push({ dimension: 'timing', ctx });
|
|
120
|
+
denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' });
|
|
99
121
|
}
|
|
100
122
|
for (const comparison of facts.comparisons) {
|
|
101
123
|
if (comparison.left.kind !== 'window_spent')
|
|
102
124
|
continue;
|
|
103
125
|
const mutated = mutateBigIntRecord(permitCtx, 'windowSpentByToken', comparison.left.token, comparison, false);
|
|
104
126
|
if (mutated)
|
|
105
|
-
denies.push({ dimension: 'time_window', ctx: mutated });
|
|
127
|
+
denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
|
|
106
128
|
}
|
|
107
129
|
for (const comparison of facts.comparisons) {
|
|
108
130
|
if (comparison.left.kind !== 'invocation_count_in_window')
|
|
@@ -111,7 +133,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
111
133
|
continue;
|
|
112
134
|
const ctx = cloneContext(permitCtx);
|
|
113
135
|
ctx.invocationCountByWindow[comparison.left.windowSecs] = violatingNumber(comparison.op, comparison.right.value);
|
|
114
|
-
denies.push({ dimension: 'invocation_count', ctx });
|
|
136
|
+
denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' });
|
|
115
137
|
}
|
|
116
138
|
// Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
|
|
117
139
|
// `call_arg[0] <= limit`). A violating deny case pushes the arg past the
|
|
@@ -126,7 +148,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
126
148
|
continue;
|
|
127
149
|
const ctx = cloneContext(permitCtx);
|
|
128
150
|
ctx.args[comparison.left.index] = violating;
|
|
129
|
-
denies.push({ dimension: 'arg_amount_bound', ctx });
|
|
151
|
+
denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' });
|
|
130
152
|
}
|
|
131
153
|
const argumentConstraints = [];
|
|
132
154
|
for (const comparison of facts.comparisons) {
|
|
@@ -144,6 +166,15 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
144
166
|
for (const constraint of argumentConstraints) {
|
|
145
167
|
const ctx = cloneContext(permitCtx);
|
|
146
168
|
ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' };
|
|
169
|
+
// The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
|
|
170
|
+
// depends on the predicate shape:
|
|
171
|
+
// - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
|
|
172
|
+
// - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
|
|
173
|
+
// (opaque needles fail-closed at the `in` membership gate, step 5)
|
|
174
|
+
// Both are correct in the TS evaluator; the reason is predicate-dependent
|
|
175
|
+
// so we cannot pin a single canonical reason here. The boolean decision
|
|
176
|
+
// is what we strictly need to assert; the reason is recorded (not asserted)
|
|
177
|
+
// when the harness runs this case.
|
|
147
178
|
denies.push({ dimension: 'arg_bound', ctx });
|
|
148
179
|
}
|
|
149
180
|
const scopedArgumentIndices = new Set(argumentConstraints.map(({ index }) => index));
|
|
@@ -163,6 +194,14 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
163
194
|
for (const index of scopedArgumentIndices) {
|
|
164
195
|
ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' };
|
|
165
196
|
}
|
|
197
|
+
// `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
|
|
198
|
+
// The first failing child of the AND decides the reason, and the contract
|
|
199
|
+
// check (step 3) does fire first in the evaluator. The reason is therefore
|
|
200
|
+
// CONTRACT_SCOPE for the canonical case, but a predicate that lists the
|
|
201
|
+
// call_fn leaf first (or that has a `call_arg_field` for that arg index)
|
|
202
|
+
// can flip the order. Pin the assertion here only when no `in` /
|
|
203
|
+
// call_arg_field binds the same arg index exist - the canonical blend
|
|
204
|
+
// case does have those binds, so the assertion would fire there.
|
|
166
205
|
denies.push({ dimension: 'scope_contract_fn_arg', ctx });
|
|
167
206
|
}
|
|
168
207
|
const oracleComparisons = facts.comparisons.filter((node) => node.left.kind === 'oracle_price');
|
|
@@ -172,7 +211,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
172
211
|
continue;
|
|
173
212
|
const ctx = cloneContext(permitCtx);
|
|
174
213
|
ctx.oraclePriceByAsset[comparison.left.asset] = { error };
|
|
175
|
-
denies.push({ dimension, ctx });
|
|
214
|
+
denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] });
|
|
176
215
|
}
|
|
177
216
|
}
|
|
178
217
|
for (const comparison of facts.comparisons) {
|
|
@@ -225,10 +264,10 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
225
264
|
if (sel.kind !== 'call_arg_field')
|
|
226
265
|
continue;
|
|
227
266
|
const arg = permitCtx.args[sel.index];
|
|
228
|
-
if (
|
|
267
|
+
if (arg?.type !== 'vec')
|
|
229
268
|
continue;
|
|
230
269
|
const element = arg.value[sel.element];
|
|
231
|
-
if (
|
|
270
|
+
if (element?.type !== 'map' || !Array.isArray(element.value))
|
|
232
271
|
continue;
|
|
233
272
|
const entry = element.value.find((e) => e.key === sel.field);
|
|
234
273
|
if (!entry)
|
|
@@ -256,7 +295,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
|
|
|
256
295
|
if (sel.kind !== 'call_arg_len')
|
|
257
296
|
continue;
|
|
258
297
|
const arg = permitCtx.args[sel.index];
|
|
259
|
-
if (
|
|
298
|
+
if (arg?.type !== 'vec')
|
|
260
299
|
continue;
|
|
261
300
|
const ctx = cloneContext(permitCtx);
|
|
262
301
|
ctx.args[sel.index] = {
|
package/dist/synth/evaluate.js
CHANGED
|
@@ -1,33 +1,23 @@
|
|
|
1
|
-
// src/synth/evaluate.ts - the model evaluator.
|
|
1
|
+
// src/synth/evaluate.ts - the model evaluator (TS model of interpreter semantics).
|
|
2
2
|
//
|
|
3
|
-
// Pure function.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
|
|
4
|
+
// no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
|
|
5
|
+
// 1. ledger expiry -> 'EXPIRED'
|
|
6
|
+
// 2. `now` past valid_until -> 'EXPIRED'
|
|
7
|
+
// 3. contract mismatch -> 'CONTRACT_SCOPE'
|
|
8
|
+
// 4. per-ScVal equality on fn/args; EXACT ordered vector
|
|
9
|
+
// equality; fail-closed on opaque args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
|
|
10
|
+
// 5. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
|
|
11
|
+
// 6. amount / window_spent via BigInt -> 'AMOUNT_BOUND'
|
|
12
|
+
// 7. invocation_count_in_window -> 'FREQUENCY'
|
|
13
|
+
// 8. oracle_price: fatal -> throw OracleError, deny with
|
|
14
|
+
// underlying ORACLE_* reason -> 'ORACLE_*'
|
|
15
|
+
// 9. boolean nodes (and/or/not)
|
|
16
|
+
// 10. signer threshold gate -> 'THRESHOLD_NOT_MET'
|
|
17
|
+
// 11. otherwise permit.
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 2. a `now` leaf compared past `valid_until` semantics -> 'EXPIRED'
|
|
11
|
-
// 3. a `call_contract` eq whose literal != ctx.contract -> 'CONTRACT_SCOPE'
|
|
12
|
-
// 4. per-ScVal equality on `call_fn` / `call_arg[i]`;
|
|
13
|
-
// `eq(selector, literal_vec)` = EXACT ordered vector equality
|
|
14
|
-
// (compare element-by-element, deny if length or any differs);
|
|
15
|
-
// fail-closed on opaque/undecodable args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
|
|
16
|
-
// 5. `in` membership: needle NOT in haystack; an EMPTY
|
|
17
|
-
// haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
|
|
18
|
-
// 6. amount / window_spent comparisons via BigInt -> 'AMOUNT_BOUND'
|
|
19
|
-
// 7. `invocation_count_in_window` compare -> 'FREQUENCY'
|
|
20
|
-
// 8. `oracle_price`: missing/stale/deviation/etc. is FATAL -
|
|
21
|
-
// throw `OracleError`, catch at the top, deny with the
|
|
22
|
-
// underlying `ORACLE_*` reason -> 'ORACLE_*'
|
|
23
|
-
// 9. boolean nodes: `and` = all children permit;
|
|
24
|
-
// `or` = any child permits; `not` = invert a non-oracle child
|
|
25
|
-
// 10. signer threshold gate (when `signerWeights` provided,
|
|
26
|
-
// no positive-weight signer -> deny 'THRESHOLD_NOT_MET')
|
|
27
|
-
// 11. otherwise `{ permit: true }`.
|
|
28
|
-
//
|
|
29
|
-
// Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
|
|
30
|
-
// modelled by throwing (never a boolean-false that `not`/`or` could mask).
|
|
19
|
+
// Amounts: BigInt on decimal strings. Oracle errors thrown (never
|
|
20
|
+
// boolean-false `not`/`or` could mask).
|
|
31
21
|
import { literalNumericBigInt } from "./predicate-literals.js";
|
|
32
22
|
/** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
|
|
33
23
|
* in oracle.rs. A threshold on any other basis must say so. */
|
|
@@ -35,8 +25,8 @@ const NORMALISED_DECIMALS = 9;
|
|
|
35
25
|
/** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
|
|
36
26
|
const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
|
|
37
27
|
/** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
|
|
38
|
-
* and converted to the matching `ORACLE_*` deny reason. NOT a
|
|
39
|
-
*
|
|
28
|
+
* and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
|
|
29
|
+
* that `not` / `or` could mask. */
|
|
40
30
|
class OracleError extends Error {
|
|
41
31
|
code;
|
|
42
32
|
constructor(code) {
|
|
@@ -90,12 +80,9 @@ function walk(node, ctx) {
|
|
|
90
80
|
for (const child of node.children) {
|
|
91
81
|
const r = walk(child, ctx);
|
|
92
82
|
if (!r.permit) {
|
|
93
|
-
// deny-on-first: short-circuit
|
|
94
|
-
// child, so the reason we surface is from that child.
|
|
83
|
+
// deny-on-first: short-circuit on the failing child.
|
|
95
84
|
return r;
|
|
96
85
|
}
|
|
97
|
-
// keep a reference to the last permit so unused-variable analysis
|
|
98
|
-
// doesn't complain; this is purely structural.
|
|
99
86
|
lastDeny = r;
|
|
100
87
|
}
|
|
101
88
|
return lastDeny ?? { permit: true };
|
|
@@ -111,11 +98,9 @@ function walk(node, ctx) {
|
|
|
111
98
|
return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' };
|
|
112
99
|
}
|
|
113
100
|
case 'not': {
|
|
114
|
-
// `not` structurally inverts the child
|
|
115
|
-
// oracle leaf (compile-time rule: no oracle leaf under not/or).
|
|
116
|
-
//
|
|
117
|
-
// `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
|
|
118
|
-
// deny is inverted to a permit, and a permit is inverted to a deny.
|
|
101
|
+
// `not` structurally inverts the child unless the child contains an
|
|
102
|
+
// oracle leaf (compile-time rule: no oracle leaf under not/or). A
|
|
103
|
+
// child `OracleError` is re-thrown to the catch at the top of `evaluate`.
|
|
119
104
|
const r = walk(node.child, ctx);
|
|
120
105
|
if (r.permit)
|
|
121
106
|
return { permit: false, reason: 'FN_MISMATCH' };
|
|
@@ -136,17 +121,14 @@ function evalCompare(op, left, right, ctx) {
|
|
|
136
121
|
// --- step 2: `now` vs `valid_until` semantics ---
|
|
137
122
|
if (left.kind === 'now' && right.kind === 'valid_until') {
|
|
138
123
|
const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0;
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// `nowSeconds` so the only true-positive expired path is the
|
|
143
|
-
// `gt`/`gte` shapes callers actually write.
|
|
124
|
+
// `valid_until` is modelled as a synthetic future timestamp far past
|
|
125
|
+
// `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
|
|
126
|
+
// shapes callers actually write.
|
|
144
127
|
if (op === 'gt' || op === 'gte') {
|
|
145
128
|
if (ctx.nowSeconds > 0)
|
|
146
129
|
return { permit: false, reason: 'EXPIRED' };
|
|
147
130
|
}
|
|
148
131
|
else if (op === 'lt' || op === 'lte') {
|
|
149
|
-
// permits when valid_until is in the future
|
|
150
132
|
return { permit: true };
|
|
151
133
|
}
|
|
152
134
|
return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true };
|
|
@@ -178,11 +160,6 @@ function evalCompare(op, left, right, ctx) {
|
|
|
178
160
|
return evalScaledArgCompare(op, left, right, ctx);
|
|
179
161
|
}
|
|
180
162
|
const actual = ctx.args[left.index];
|
|
181
|
-
// An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
|
|
182
|
-
// compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
|
|
183
|
-
// cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
|
|
184
|
-
// in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
|
|
185
|
-
// a non-numeric arg or literal.
|
|
186
163
|
if (op !== 'eq')
|
|
187
164
|
return evalArgOrderedCompare(op, actual, right);
|
|
188
165
|
return evalArgEq(op, actual, right, ctx);
|
|
@@ -192,11 +169,11 @@ function evalCompare(op, left, right, ctx) {
|
|
|
192
169
|
if (left.kind === 'call_arg_scaled') {
|
|
193
170
|
return evalScaledArgCompare(op, right, left, ctx);
|
|
194
171
|
}
|
|
195
|
-
// --- step 4c: call_arg_len:
|
|
196
|
-
// Fails closed on a non-vec
|
|
172
|
+
// --- step 4c: call_arg_len: length of a vec-typed argument as u32.
|
|
173
|
+
// Fails closed on a non-vec / absent arg or a non-u32 literal.
|
|
197
174
|
if (left.kind === 'call_arg_len') {
|
|
198
175
|
const actual = ctx.args[left.index];
|
|
199
|
-
if (
|
|
176
|
+
if (actual?.type !== 'vec')
|
|
200
177
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
201
178
|
if (right.kind !== 'literal_u32')
|
|
202
179
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -204,16 +181,14 @@ function evalCompare(op, left, right, ctx) {
|
|
|
204
181
|
? { permit: true }
|
|
205
182
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
206
183
|
}
|
|
207
|
-
// --- step 4d: call_arg_field:
|
|
208
|
-
//
|
|
209
|
-
// out-of-range element, a missing field, a non-map element, or a type
|
|
210
|
-
// mismatch between the field ScVal and the literal leaf.
|
|
184
|
+
// --- step 4d: call_arg_field: value of a field in the map at element i of
|
|
185
|
+
// the vec at argument index. Fails closed on shape / type / range issues.
|
|
211
186
|
if (left.kind === 'call_arg_field') {
|
|
212
187
|
const actual = ctx.args[left.index];
|
|
213
|
-
if (
|
|
188
|
+
if (actual?.type !== 'vec')
|
|
214
189
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
215
190
|
const element = actual.value[left.element];
|
|
216
|
-
if (
|
|
191
|
+
if (element?.type !== 'map')
|
|
217
192
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
218
193
|
if (!Array.isArray(element.value))
|
|
219
194
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -226,7 +201,7 @@ function evalCompare(op, left, right, ctx) {
|
|
|
226
201
|
}
|
|
227
202
|
// --- step 6: AMOUNT_BOUND ---
|
|
228
203
|
if (left.kind === 'amount' && op !== 'eq') {
|
|
229
|
-
return evalAmountCompare(op, left.token, right, ctx);
|
|
204
|
+
return evalAmountCompare(op, left.token, right, ctx, 'amountByToken');
|
|
230
205
|
}
|
|
231
206
|
if (left.kind === 'window_spent' && op !== 'eq') {
|
|
232
207
|
return evalWindowSpentCompare(op, left.token, right, ctx);
|
|
@@ -247,7 +222,7 @@ function evalCompare(op, left, right, ctx) {
|
|
|
247
222
|
* sequence: compare element-by-element in order; deny if length or any
|
|
248
223
|
* element differs. Opaque args (`type: 'other'`) fail closed. */
|
|
249
224
|
function evalArgEq(op, actual, right, ctx) {
|
|
250
|
-
// eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
|
|
225
|
+
// eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
|
|
251
226
|
if (op === 'eq' && right.kind === 'literal_vec') {
|
|
252
227
|
if (actual?.type !== 'vec')
|
|
253
228
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -255,7 +230,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
255
230
|
? { permit: true }
|
|
256
231
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
257
232
|
}
|
|
258
|
-
// eq(call_arg[i], literal_address) -> address compare
|
|
259
233
|
if (op === 'eq' && right.kind === 'literal_address') {
|
|
260
234
|
if (!actual)
|
|
261
235
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -265,7 +239,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
265
239
|
? { permit: true }
|
|
266
240
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
267
241
|
}
|
|
268
|
-
// eq(call_arg[i], literal_i128) -> BigInt compare
|
|
269
242
|
if (op === 'eq' && right.kind === 'literal_i128') {
|
|
270
243
|
if (actual?.type !== 'i128')
|
|
271
244
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -273,7 +246,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
273
246
|
? { permit: true }
|
|
274
247
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
275
248
|
}
|
|
276
|
-
// eq(call_arg[i], literal_symbol)
|
|
277
249
|
if (op === 'eq' && right.kind === 'literal_symbol') {
|
|
278
250
|
if (!actual)
|
|
279
251
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -281,7 +253,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
281
253
|
? { permit: true }
|
|
282
254
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
283
255
|
}
|
|
284
|
-
// eq(call_arg[i], literal_u32)
|
|
285
256
|
if (op === 'eq' && right.kind === 'literal_u32') {
|
|
286
257
|
if (!actual)
|
|
287
258
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -289,7 +260,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
289
260
|
? { permit: true }
|
|
290
261
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
291
262
|
}
|
|
292
|
-
// eq(call_arg[i], literal_u64)
|
|
293
263
|
if (op === 'eq' && right.kind === 'literal_u64') {
|
|
294
264
|
if (!actual)
|
|
295
265
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -297,7 +267,6 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
297
267
|
? { permit: true }
|
|
298
268
|
: { permit: false, reason: 'ARG_MISMATCH' };
|
|
299
269
|
}
|
|
300
|
-
// eq(call_arg[i], literal_bytes)
|
|
301
270
|
if (op === 'eq' && right.kind === 'literal_bytes') {
|
|
302
271
|
if (!actual)
|
|
303
272
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
@@ -310,20 +279,14 @@ function evalArgEq(op, actual, right, ctx) {
|
|
|
310
279
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
311
280
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
312
281
|
}
|
|
313
|
-
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare
|
|
314
|
-
*
|
|
315
|
-
* zero
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* `left` is whichever side of the compare is NOT the scaled leaf. A
|
|
321
|
-
* scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
|
|
322
|
-
* leaves has no definable semantics). The other operand must be a
|
|
323
|
-
* numeric shape (`call_arg` carrying a number, or a numeric literal);
|
|
324
|
-
* anything else is `ARG_MISMATCH`. */
|
|
282
|
+
/** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
|
|
283
|
+
* the scaled leaf is `args[index] * num / den` (truncating toward zero). On
|
|
284
|
+
* overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
|
|
285
|
+
* denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
|
|
286
|
+
* `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
|
|
287
|
+
* (no definable semantics). The non-scaled operand must be numeric
|
|
288
|
+
* (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
|
|
325
289
|
function evalScaledArgCompare(op, left, right, ctx) {
|
|
326
|
-
// Identify which side is scaled, and pull the other side as a number.
|
|
327
290
|
let scaled;
|
|
328
291
|
let other;
|
|
329
292
|
let scaledOnRight;
|
|
@@ -338,14 +301,12 @@ function evalScaledArgCompare(op, left, right, ctx) {
|
|
|
338
301
|
scaledOnRight = true;
|
|
339
302
|
}
|
|
340
303
|
else {
|
|
341
|
-
//
|
|
342
|
-
// dispatcher; the contract returns ARG_MISMATCH to fail closed.
|
|
304
|
+
// Programming error in the dispatcher; fail closed.
|
|
343
305
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
344
306
|
}
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// not be read.
|
|
307
|
+
// Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
|
|
308
|
+
// SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
|
|
309
|
+
// itself could not be read.
|
|
349
310
|
if (scaled.index >= ctx.args.length) {
|
|
350
311
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
351
312
|
}
|
|
@@ -362,16 +323,11 @@ function evalScaledArgCompare(op, left, right, ctx) {
|
|
|
362
323
|
return { permit: false, reason: 'ARG_MISMATCH' };
|
|
363
324
|
}
|
|
364
325
|
// Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
|
|
365
|
-
// check is
|
|
366
|
-
//
|
|
326
|
+
// check is defensive belt-and-braces so a validator regression cannot
|
|
327
|
+
// panic the frame on a divide-by-zero.
|
|
367
328
|
if (den === 0n)
|
|
368
329
|
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
|
|
369
330
|
const product = input * num;
|
|
370
|
-
// BigInt overflow is silent in JS (it does not throw on multiplication).
|
|
371
|
-
// We can detect over-range by dividing and checking the result against
|
|
372
|
-
// i128 bounds, but the more practical check is whether the result
|
|
373
|
-
// divides cleanly. We rely on BigInt's arbitrary precision and then
|
|
374
|
-
// surface ARITHMETIC_OVERFLOW if the result is outside i128.
|
|
375
331
|
let scaledValue;
|
|
376
332
|
try {
|
|
377
333
|
// BigInt division truncates toward zero (matches Rust `i128::checked_div`).
|
|
@@ -380,18 +336,13 @@ function evalScaledArgCompare(op, left, right, ctx) {
|
|
|
380
336
|
catch {
|
|
381
337
|
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
|
|
382
338
|
}
|
|
383
|
-
// i128 range check:
|
|
384
|
-
//
|
|
339
|
+
// i128 range check: the contract would have wrapped on i128 arithmetic;
|
|
340
|
+
// surface the same deny.
|
|
385
341
|
const I128_MAX = (1n << 127n) - 1n;
|
|
386
342
|
const I128_MIN = -(1n << 127n);
|
|
387
343
|
if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
|
|
388
344
|
return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
|
|
389
345
|
}
|
|
390
|
-
// Resolving the operand when the scaled side is on the right: the
|
|
391
|
-
// operand is `left`; the scaled value is the RHS. The AST order is
|
|
392
|
-
// `left <op> scaled`, so the comparator applies to `other <op> scaled`.
|
|
393
|
-
// When the scaled side is on the left, the order is `scaled <op> other`,
|
|
394
|
-
// i.e. `scaled <op> other_val`.
|
|
395
346
|
let otherVal;
|
|
396
347
|
if (other.kind === 'call_arg') {
|
|
397
348
|
otherVal = argNumericBigInt(ctx.args[other.index]);
|
|
@@ -459,7 +410,6 @@ function compareVecExact(actual, expected, ctx) {
|
|
|
459
410
|
function evalIn(needle, haystack, ctx) {
|
|
460
411
|
if (haystack.length === 0)
|
|
461
412
|
return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
|
|
462
|
-
// Resolve the needle's ScVal against the candidate call.
|
|
463
413
|
const actual = resolveLeaf(needle, ctx);
|
|
464
414
|
if (!actual || actual.type === 'other')
|
|
465
415
|
return { permit: false, reason: 'NOT_IN_ALLOWLIST' };
|
|
@@ -486,10 +436,10 @@ function resolveLeaf(leaf, ctx) {
|
|
|
486
436
|
return undefined;
|
|
487
437
|
case 'call_arg_field': {
|
|
488
438
|
const actual = ctx.args[leaf.index];
|
|
489
|
-
if (
|
|
439
|
+
if (actual?.type !== 'vec')
|
|
490
440
|
return undefined;
|
|
491
441
|
const element = actual.value[leaf.element];
|
|
492
|
-
if (
|
|
442
|
+
if (element?.type !== 'map')
|
|
493
443
|
return undefined;
|
|
494
444
|
if (!Array.isArray(element.value))
|
|
495
445
|
return undefined;
|
|
@@ -520,25 +470,21 @@ function resolveLeaf(leaf, ctx) {
|
|
|
520
470
|
return undefined;
|
|
521
471
|
}
|
|
522
472
|
}
|
|
523
|
-
/** Step 6: amount compare on BigInt.
|
|
524
|
-
|
|
473
|
+
/** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
|
|
474
|
+
* per-token BigInt record (current vs. rolling) to an i128 literal; the
|
|
475
|
+
* compare + denial reason are identical, so a single helper handles both. */
|
|
476
|
+
function evalAmountCompare(op, token, right, ctx, record) {
|
|
525
477
|
const literal = right.kind === 'literal_i128' ? right.value : null;
|
|
526
478
|
if (literal === null)
|
|
527
479
|
return { permit: false, reason: 'AMOUNT_BOUND' };
|
|
528
|
-
const actual = ctx
|
|
480
|
+
const actual = ctx[record][token] ?? '0';
|
|
529
481
|
return bigintCmp(op, actual, literal)
|
|
530
482
|
? { permit: true }
|
|
531
483
|
: { permit: false, reason: 'AMOUNT_BOUND' };
|
|
532
484
|
}
|
|
533
485
|
/** Step 6: window_spent compare on BigInt. */
|
|
534
486
|
function evalWindowSpentCompare(op, token, right, ctx) {
|
|
535
|
-
|
|
536
|
-
if (literal === null)
|
|
537
|
-
return { permit: false, reason: 'AMOUNT_BOUND' };
|
|
538
|
-
const actual = ctx.windowSpentByToken[token] ?? '0';
|
|
539
|
-
return bigintCmp(op, actual, literal)
|
|
540
|
-
? { permit: true }
|
|
541
|
-
: { permit: false, reason: 'AMOUNT_BOUND' };
|
|
487
|
+
return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken');
|
|
542
488
|
}
|
|
543
489
|
/** Step 7: invocation_count_in_window compare. */
|
|
544
490
|
function evalFrequencyCompare(op, windowSecs, right, ctx) {
|
|
@@ -562,24 +508,28 @@ function evalOracleCompare(op, asset, right, ctx) {
|
|
|
562
508
|
throw new OracleError(mapped);
|
|
563
509
|
}
|
|
564
510
|
// Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
|
|
565
|
-
// decimal basis: prices are on the normalised 9-dp basis, and
|
|
566
|
-
//
|
|
567
|
-
//
|
|
511
|
+
// decimal basis: prices are on the normalised 9-dp basis, and a bare literal
|
|
512
|
+
// is refused (not assumed) because a raw 14-dp threshold that assumed the
|
|
513
|
+
// 9-dp basis would permit everything.
|
|
568
514
|
if (right.kind !== 'oracle_threshold')
|
|
569
515
|
throw new OracleError('ORACLE_DECIMALS_MISMATCH');
|
|
570
516
|
if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
|
|
571
517
|
throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
|
|
572
518
|
}
|
|
573
|
-
// Scale BOTH sides up to the wider basis
|
|
574
|
-
//
|
|
519
|
+
// Scale BOTH sides up to the wider basis; dividing the threshold down
|
|
520
|
+
// truncates and moves the permit boundary.
|
|
575
521
|
const decimals = BigInt(right.decimals);
|
|
576
522
|
const normalised = BigInt(NORMALISED_DECIMALS);
|
|
577
523
|
const common = decimals > normalised ? decimals : normalised;
|
|
578
524
|
const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
|
|
579
525
|
const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
|
|
526
|
+
// A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
|
|
527
|
+
// `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
|
|
528
|
+
// the same reason so a future TS/Rust reason divergence fails CI; the
|
|
529
|
+
// cross-layer harness in this file now asserts reason codes match.
|
|
580
530
|
return bigintCmp(op, String(priceScaled), String(literalScaled))
|
|
581
531
|
? { permit: true }
|
|
582
|
-
: { permit: false, reason: '
|
|
532
|
+
: { permit: false, reason: 'STATEFUL_BOUND' };
|
|
583
533
|
}
|
|
584
534
|
/** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
|
|
585
535
|
* equal checks), but the dedicated arg path uses it via `BigInt(actual) === BigInt(right)`. */
|
package/dist/synth/harness.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export interface HarnessFailure {
|
|
|
5
5
|
expected: 'permit' | 'deny';
|
|
6
6
|
got: 'permit' | 'deny';
|
|
7
7
|
reason?: string;
|
|
8
|
+
/** When `expectedReason` is set on the deny case and the case actually
|
|
9
|
+
* denies, this carries the canonical reason the TS evaluator MUST emit
|
|
10
|
+
* to stay in lockstep with the Rust interpreter (the cross-layer
|
|
11
|
+
* reason-code contract). A future TS/Rust reason divergence surfaces
|
|
12
|
+
* here in CI. */
|
|
13
|
+
expectedReason?: string;
|
|
14
|
+
actualReason?: string;
|
|
8
15
|
}
|
|
9
16
|
export type HarnessResult = {
|
|
10
17
|
ok: true;
|
|
@@ -12,5 +19,10 @@ export type HarnessResult = {
|
|
|
12
19
|
ok: false;
|
|
13
20
|
failures: HarnessFailure[];
|
|
14
21
|
};
|
|
15
|
-
/** Evaluate the intended call and every generated denial without side effects.
|
|
22
|
+
/** Evaluate the intended call and every generated denial without side effects.
|
|
23
|
+
* Asserts both the boolean decision AND, for deny cases that carry an
|
|
24
|
+
* `expectedReason`, the concrete reason the TS evaluator emits. The reason
|
|
25
|
+
* is what the Rust interpreter returns; the cross-layer conformance test in
|
|
26
|
+
* `contracts/policy-interpreter/tests/conformance` mirrors the same contract
|
|
27
|
+
* for the verifyLive path. */
|
|
16
28
|
export declare function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult;
|
package/dist/synth/harness.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { evaluate } from "./evaluate.js";
|
|
2
|
-
/** Evaluate the intended call and every generated denial without side effects.
|
|
2
|
+
/** Evaluate the intended call and every generated denial without side effects.
|
|
3
|
+
* Asserts both the boolean decision AND, for deny cases that carry an
|
|
4
|
+
* `expectedReason`, the concrete reason the TS evaluator emits. The reason
|
|
5
|
+
* is what the Rust interpreter returns; the cross-layer conformance test in
|
|
6
|
+
* `contracts/policy-interpreter/tests/conformance` mirrors the same contract
|
|
7
|
+
* for the verifyLive path. */
|
|
3
8
|
export function runHarness(predicate, cases) {
|
|
4
9
|
const failures = [];
|
|
5
10
|
const permitResult = evaluate(predicate, cases.permit);
|
|
@@ -20,6 +25,22 @@ export function runHarness(predicate, cases) {
|
|
|
20
25
|
got: 'permit',
|
|
21
26
|
reason: 'DENY_CASE_FAILURE',
|
|
22
27
|
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Reason-code assertion (cross-layer contract). When a deny case carries
|
|
31
|
+
// an expected reason, the TS evaluator MUST emit that exact string; a
|
|
32
|
+
// divergence means the TS model and the Rust interpreter disagree on
|
|
33
|
+
// what the user sees in CI - the harness fails so the diff is visible
|
|
34
|
+
// here, not at runtime in production.
|
|
35
|
+
if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
|
|
36
|
+
failures.push({
|
|
37
|
+
dimension: deny.dimension,
|
|
38
|
+
expected: 'deny',
|
|
39
|
+
got: 'deny',
|
|
40
|
+
reason: 'REASON_MISMATCH',
|
|
41
|
+
expectedReason: deny.expectedReason,
|
|
42
|
+
actualReason: result.reason,
|
|
43
|
+
});
|
|
23
44
|
}
|
|
24
45
|
}
|
|
25
46
|
return failures.length === 0 ? { ok: true } : { ok: false, failures };
|
package/dist/synth/index.d.ts
CHANGED
|
@@ -7,4 +7,4 @@ export { type IntentFacts, lower } from './lower.ts';
|
|
|
7
7
|
export { minimize } from './minimize.ts';
|
|
8
8
|
export { type DecideScopeOptions, decideScope, type ScopeDecision, scopeToContextRuleType, } from './scope.ts';
|
|
9
9
|
export { synthesizeFromMandate } from './synthesize-from-mandate.ts';
|
|
10
|
-
export { type SynthesizeFromRecordingOptions, synthesizeFromRecording, } from './synthesize-from-recording.ts';
|
|
10
|
+
export { type __TestInterpreterAdapterOptions, type SynthesizeFromRecordingOptions, synthesizeFromRecording, } from './synthesize-from-recording.ts';
|