@crediolabs/policy-synth 0.1.9 → 0.1.11
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/dist/adapters/interpreter/adapter.js +62 -11
- package/dist/errors.d.ts +1 -1
- package/dist/install/build-add-context-rule.d.ts +48 -0
- package/dist/install/build-add-context-rule.js +304 -0
- package/dist/ir/types.d.ts +4 -0
- package/dist/predicate/decode.d.ts +11 -0
- package/dist/predicate/decode.js +234 -0
- package/dist/predicate/encode.js +79 -14
- package/dist/predicate/from-json.d.ts +4 -0
- package/dist/predicate/from-json.js +113 -0
- package/dist/predicate/index.d.ts +2 -0
- package/dist/predicate/index.js +5 -1
- package/dist/registry/identify.js +21 -2
- package/dist/registry/protocols.d.ts +50 -1
- package/dist/registry/protocols.js +88 -0
- package/dist/review-card/builder.d.ts +13 -0
- package/dist/review-card/builder.js +61 -5
- package/dist/review-card/cross-check.js +50 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +16 -4
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.d.ts +4 -0
- package/dist/synth/compose-from-recording.js +34 -8
- package/dist/synth/deny-cases.d.ts +4 -1
- package/dist/synth/deny-cases.js +3 -1
- package/dist/synth/evaluate.js +130 -3
- package/dist/synth/permit-context.d.ts +15 -0
- package/dist/synth/permit-context.js +116 -0
- package/dist/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist/synth/synthesize-from-mandate.js +27 -2
- package/dist/synth/synthesize-from-recording.d.ts +25 -1
- package/dist/synth/synthesize-from-recording.js +80 -3
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +62 -11
- package/dist-cjs/errors.d.ts +1 -1
- package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
- package/dist-cjs/install/build-add-context-rule.js +308 -0
- package/dist-cjs/ir/types.d.ts +4 -0
- package/dist-cjs/predicate/decode.d.ts +11 -0
- package/dist-cjs/predicate/decode.js +239 -0
- package/dist-cjs/predicate/encode.js +79 -14
- package/dist-cjs/predicate/from-json.d.ts +4 -0
- package/dist-cjs/predicate/from-json.js +116 -0
- package/dist-cjs/predicate/index.d.ts +2 -0
- package/dist-cjs/predicate/index.js +10 -2
- package/dist-cjs/registry/identify.js +20 -1
- package/dist-cjs/registry/protocols.d.ts +50 -1
- package/dist-cjs/registry/protocols.js +89 -1
- package/dist-cjs/review-card/builder.d.ts +13 -0
- package/dist-cjs/review-card/builder.js +62 -5
- package/dist-cjs/review-card/cross-check.js +50 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +16 -4
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
- package/dist-cjs/synth/compose-from-recording.js +34 -8
- package/dist-cjs/synth/deny-cases.d.ts +4 -1
- package/dist-cjs/synth/deny-cases.js +3 -0
- package/dist-cjs/synth/evaluate.js +130 -3
- package/dist-cjs/synth/permit-context.d.ts +15 -0
- package/dist-cjs/synth/permit-context.js +119 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
- package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
- package/dist-cjs/synth/synthesize-from-recording.js +80 -3
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +72 -11
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
- package/src/errors.ts +13 -0
- package/src/install/build-add-context-rule.ts +429 -0
- package/src/ir/types.ts +4 -0
- package/src/predicate/decode.ts +242 -0
- package/src/predicate/encode.ts +110 -13
- package/src/predicate/from-json.ts +124 -0
- package/src/predicate/index.ts +5 -1
- package/src/registry/identify.ts +22 -2
- package/src/registry/protocols.ts +90 -1
- package/src/review-card/builder.ts +57 -5
- package/src/review-card/cross-check.ts +50 -3
- package/src/review-card/index.ts +1 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +46 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +131 -3
- package/src/synth/permit-context.ts +137 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +116 -6
- package/src/types.ts +12 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/synth/permit-context.ts - build the EvalContext that represents the
|
|
3
|
+
// recorded call being replayed.
|
|
4
|
+
//
|
|
5
|
+
// Extracted from the over-permissiveness harness so the same construction
|
|
6
|
+
// feeds both the in-process battery and the on-chain replay
|
|
7
|
+
// (scripts/verify-mutations-testnet.ts). Two callers building this
|
|
8
|
+
// separately would be two chances to disagree about what "the recorded
|
|
9
|
+
// call" means, which is the one thing both must share.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.buildPermitContext = buildPermitContext;
|
|
12
|
+
const deny_cases_ts_1 = require("./deny-cases.js");
|
|
13
|
+
function buildPermitContext(tx, responses, predicate) {
|
|
14
|
+
const amountByToken = {};
|
|
15
|
+
const totals = new Map();
|
|
16
|
+
for (const m of tx.tokenMovements) {
|
|
17
|
+
const current = totals.get(m.token) ?? 0n;
|
|
18
|
+
totals.set(m.token, current + BigInt(m.amount));
|
|
19
|
+
}
|
|
20
|
+
for (const [token, total] of totals) {
|
|
21
|
+
amountByToken[token] = total.toString();
|
|
22
|
+
}
|
|
23
|
+
const topLevel = tx.invocations[0];
|
|
24
|
+
const scopeContract = topLevel?.contract ?? '';
|
|
25
|
+
const oraclePriceByAsset = {};
|
|
26
|
+
visitOracleLeaves(predicate, (asset, op, bound) => {
|
|
27
|
+
let price;
|
|
28
|
+
switch (op) {
|
|
29
|
+
case 'lt':
|
|
30
|
+
price = bound - 1n;
|
|
31
|
+
break;
|
|
32
|
+
case 'gt':
|
|
33
|
+
price = bound + 1n;
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
price = bound;
|
|
37
|
+
}
|
|
38
|
+
if (price < 0n)
|
|
39
|
+
price = 0n;
|
|
40
|
+
oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
|
|
41
|
+
});
|
|
42
|
+
const ctx = {
|
|
43
|
+
contract: scopeContract,
|
|
44
|
+
fn: topLevel?.fn ?? '',
|
|
45
|
+
args: (topLevel?.args ?? []).map(deny_cases_ts_1.cloneScVal),
|
|
46
|
+
atLedger: tx.ledgerSequence,
|
|
47
|
+
nowSeconds: tx.fetchedAt,
|
|
48
|
+
amountByToken,
|
|
49
|
+
windowSpentByToken: {},
|
|
50
|
+
invocationCountByWindow: {},
|
|
51
|
+
oraclePriceByAsset,
|
|
52
|
+
};
|
|
53
|
+
if (responses.validUntilLedger !== undefined) {
|
|
54
|
+
ctx.validUntilLedger = responses.validUntilLedger;
|
|
55
|
+
}
|
|
56
|
+
return ctx;
|
|
57
|
+
}
|
|
58
|
+
function visitOracleLeaves(node, visit) {
|
|
59
|
+
switch (node.op) {
|
|
60
|
+
case 'and':
|
|
61
|
+
case 'or':
|
|
62
|
+
for (const child of node.children)
|
|
63
|
+
visitOracleLeaves(child, visit);
|
|
64
|
+
return;
|
|
65
|
+
case 'not':
|
|
66
|
+
visitOracleLeaves(node.child, visit);
|
|
67
|
+
return;
|
|
68
|
+
case 'eq':
|
|
69
|
+
case 'lt':
|
|
70
|
+
case 'lte':
|
|
71
|
+
case 'gt':
|
|
72
|
+
case 'gte': {
|
|
73
|
+
const leftIsOracle = node.left.kind === 'oracle_price';
|
|
74
|
+
const rightIsOracle = node.right.kind === 'oracle_price';
|
|
75
|
+
let asset;
|
|
76
|
+
let literal;
|
|
77
|
+
if (leftIsOracle) {
|
|
78
|
+
asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
|
|
79
|
+
literal = oracleThresholdNormalised(node.right);
|
|
80
|
+
}
|
|
81
|
+
else if (rightIsOracle) {
|
|
82
|
+
asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
|
|
83
|
+
literal = oracleThresholdNormalised(node.left);
|
|
84
|
+
}
|
|
85
|
+
if (asset !== undefined && literal !== undefined) {
|
|
86
|
+
visit(asset, node.op, literal);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case 'in':
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
|
|
95
|
+
* oracle.rs. */
|
|
96
|
+
const NORMALISED_DECIMALS = 9n;
|
|
97
|
+
/** A threshold restated on the normalised basis, so a derived permit price is
|
|
98
|
+
* comparable to it. Thresholds carry their own basis, so the conversion has
|
|
99
|
+
* to happen here - reading the digits raw would build a context off by a
|
|
100
|
+
* factor of 10^(decimals-9). Returns undefined for any other leaf. */
|
|
101
|
+
function oracleThresholdNormalised(leaf) {
|
|
102
|
+
if (leaf.kind !== 'oracle_threshold')
|
|
103
|
+
return undefined;
|
|
104
|
+
let value;
|
|
105
|
+
try {
|
|
106
|
+
value = BigInt(leaf.value);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const decimals = BigInt(leaf.decimals);
|
|
112
|
+
if (decimals <= NORMALISED_DECIMALS) {
|
|
113
|
+
return value * 10n ** (NORMALISED_DECIMALS - decimals);
|
|
114
|
+
}
|
|
115
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation. The
|
|
116
|
+
// caller only needs a price that lands on the right side of the bound, and
|
|
117
|
+
// it offsets by one from here.
|
|
118
|
+
return value / 10n ** (decimals - NORMALISED_DECIMALS);
|
|
119
|
+
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolResponse } from '../errors.ts';
|
|
3
3
|
import type { MandateSpec } from '../mandate/types.ts';
|
|
4
|
-
import type { ProposedPolicy } from '../types.ts';
|
|
5
|
-
|
|
4
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts';
|
|
5
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
6
|
+
export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig,
|
|
7
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
8
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
9
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
10
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
11
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
12
|
+
* altered. */
|
|
13
|
+
opts?: {
|
|
14
|
+
explain?: true;
|
|
15
|
+
}): ToolResponse<ProposedPolicy> & {
|
|
16
|
+
explain?: {
|
|
17
|
+
predicateTree: PredicateNode | null;
|
|
18
|
+
simulation: SimulationResult;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -13,7 +13,14 @@ exports.synthesizeFromMandate = synthesizeFromMandate;
|
|
|
13
13
|
const adapter_ts_1 = require("../adapters/oz/adapter.js");
|
|
14
14
|
const to_ir_ts_1 = require("../mandate/to-ir.js");
|
|
15
15
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
16
|
-
function synthesizeFromMandate(spec, ozConfig
|
|
16
|
+
function synthesizeFromMandate(spec, ozConfig,
|
|
17
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
18
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
19
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
20
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
21
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
22
|
+
* altered. */
|
|
23
|
+
opts) {
|
|
17
24
|
const ir = (0, to_ir_ts_1.mandateToPolicyIR)(spec);
|
|
18
25
|
const adapter = (0, adapter_ts_1.createOzAdapter)(ozConfig);
|
|
19
26
|
const result = adapter.compile(ir);
|
|
@@ -33,5 +40,23 @@ function synthesizeFromMandate(spec, ozConfig) {
|
|
|
33
40
|
...result.proposed,
|
|
34
41
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
35
42
|
};
|
|
36
|
-
|
|
43
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
44
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
45
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
46
|
+
const envelope = { ok: true, data: proposed };
|
|
47
|
+
if (opts?.explain) {
|
|
48
|
+
envelope.explain = {
|
|
49
|
+
predicateTree: null,
|
|
50
|
+
simulation: {
|
|
51
|
+
permit: {
|
|
52
|
+
tx: 'deny',
|
|
53
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
54
|
+
},
|
|
55
|
+
evaluatedCases: [],
|
|
56
|
+
backend: 'ts-model',
|
|
57
|
+
simulatorVersion: 'not-run',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return envelope;
|
|
37
62
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolError, ToolResponse } from '../errors.ts';
|
|
3
3
|
import { type Network, type PredicateNode, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
|
|
4
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
4
5
|
import { type ComposeUserResponses } from './compose-from-recording.ts';
|
|
5
6
|
/** Per-policy interpreter adapter config the caller opts into. Absent ->
|
|
6
7
|
* recording path runs in week-1 mode (OZ adapter only, warnings for the
|
|
@@ -41,8 +42,31 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
41
42
|
threshold: number;
|
|
42
43
|
};
|
|
43
44
|
interpreter?: InterpreterAdapterOptions;
|
|
45
|
+
/** --explain opt-in. When true, the orchestrator attaches the
|
|
46
|
+
* in-memory `PredicateNode` + the corresponding `SimulationResult`
|
|
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. */
|
|
52
|
+
explain?: true;
|
|
44
53
|
}
|
|
45
|
-
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy
|
|
54
|
+
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
|
|
55
|
+
/** Present iff `opts.explain === true` and the synthesis succeeded. The
|
|
56
|
+
* `predicateTree` is the exact in-memory `PredicateNode` (canonical
|
|
57
|
+
* JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
|
|
58
|
+
* - reading the predicate tree from the policy document is therefore
|
|
59
|
+
* unnecessary; the orchestrator carries it through. The `simulation`
|
|
60
|
+
* is the verdict produced by the same self-verify pipeline that already
|
|
61
|
+
* runs in the recording path (runHarness + evaluate), so the value is
|
|
62
|
+
* REAL not synthetic. Absent when the synthesis did not engage the
|
|
63
|
+
* interpreter (no interpreter opts supplied) - in that case the CLI
|
|
64
|
+
* builds a minimal honest SimulationResult downstream. */
|
|
65
|
+
explain?: {
|
|
66
|
+
predicateTree: PredicateNode | null;
|
|
67
|
+
simulation: SimulationResult;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
46
70
|
/** ToolError-shaped error helper used by the body to surface a structured
|
|
47
71
|
* failure that the envelope converts to `{ok:false, error}`. */
|
|
48
72
|
declare function throwToolError(code: ToolError['code'], message: string): never;
|
|
@@ -199,6 +199,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
199
199
|
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
200
200
|
};
|
|
201
201
|
const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
|
|
202
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
203
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
204
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
205
|
+
// already gates the synthesis (it is not a parallel simulation); the
|
|
206
|
+
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
207
|
+
// after the gate, so the explain hook reuses them - no extra work.
|
|
208
|
+
let explain = null;
|
|
209
|
+
let explainSim = null;
|
|
202
210
|
// 5. OZ compile (always runs).
|
|
203
211
|
const ozAdapter = (0, adapter_ts_2.createOzAdapter)(ozConfig);
|
|
204
212
|
const compileRes = ozAdapter.compile(composed.ir);
|
|
@@ -376,6 +384,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
376
384
|
},
|
|
377
385
|
};
|
|
378
386
|
}
|
|
387
|
+
// --explain capture: the interpreter path already produced the real
|
|
388
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
389
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
390
|
+
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
391
|
+
// each deny case to surface its concrete reason; the harness only
|
|
392
|
+
// records whether the got-matches-expected boundary held.
|
|
393
|
+
if (opts.explain) {
|
|
394
|
+
explain = finalPredicate;
|
|
395
|
+
const evaluatedCases = [
|
|
396
|
+
{
|
|
397
|
+
dimension: 'permit',
|
|
398
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
399
|
+
reason: 'matches recorded call',
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
for (const deny of harnessCases.denies) {
|
|
403
|
+
const r = (0, evaluate_ts_1.evaluate)(finalPredicate, deny.ctx);
|
|
404
|
+
evaluatedCases.push({
|
|
405
|
+
dimension: deny.dimension,
|
|
406
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
407
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
explainSim = {
|
|
411
|
+
permit: { tx: 'permit' },
|
|
412
|
+
evaluatedCases,
|
|
413
|
+
backend: 'ts-model',
|
|
414
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
379
417
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
380
418
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
381
419
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -473,7 +511,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
473
511
|
],
|
|
474
512
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
475
513
|
};
|
|
476
|
-
|
|
514
|
+
// --explain success envelope. The interpreter path populated
|
|
515
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
516
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
517
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
518
|
+
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
519
|
+
// interpreter was never engaged, so permit is deny with a truthful
|
|
520
|
+
// reason and evaluatedCases is empty.
|
|
521
|
+
const envelope = { ok: true, data: proposed };
|
|
522
|
+
if (opts.explain) {
|
|
523
|
+
if (explainSim) {
|
|
524
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim };
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
envelope.explain = {
|
|
528
|
+
predicateTree: null,
|
|
529
|
+
simulation: {
|
|
530
|
+
permit: {
|
|
531
|
+
tx: 'deny',
|
|
532
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
533
|
+
},
|
|
534
|
+
evaluatedCases: [],
|
|
535
|
+
backend: 'ts-model',
|
|
536
|
+
simulatorVersion: 'not-run',
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return envelope;
|
|
477
542
|
}
|
|
478
543
|
/** OZ-side `uncovered` warning patterns that the interpreter adapter
|
|
479
544
|
* actually lowers when wired in. When the interpreter adapter succeeds, we
|
|
@@ -747,13 +812,25 @@ function visitOracleLeaves(node, visit) {
|
|
|
747
812
|
return;
|
|
748
813
|
}
|
|
749
814
|
}
|
|
815
|
+
/** An oracle threshold restated on the normalised 9-dp basis prices use.
|
|
816
|
+
* Thresholds carry their own basis, so reading the digits raw would build a
|
|
817
|
+
* permit context off by 10^(decimals-9) and the intended call would not
|
|
818
|
+
* satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
|
|
750
819
|
function oracleLiteralFromLeaf(leaf) {
|
|
751
|
-
if (leaf.kind !== '
|
|
820
|
+
if (leaf.kind !== 'oracle_threshold')
|
|
752
821
|
return undefined;
|
|
822
|
+
let value;
|
|
753
823
|
try {
|
|
754
|
-
|
|
824
|
+
value = BigInt(leaf.value);
|
|
755
825
|
}
|
|
756
826
|
catch {
|
|
757
827
|
return undefined;
|
|
758
828
|
}
|
|
829
|
+
const normalised = 9n;
|
|
830
|
+
const decimals = BigInt(leaf.decimals);
|
|
831
|
+
if (decimals <= normalised)
|
|
832
|
+
return value * 10n ** (normalised - decimals);
|
|
833
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation; the
|
|
834
|
+
// caller offsets by one from here to land on the right side of the bound.
|
|
835
|
+
return value / 10n ** (decimals - normalised);
|
|
759
836
|
}
|
package/dist-cjs/types.d.ts
CHANGED
|
@@ -190,6 +190,11 @@ export type PredicateLeaf = {
|
|
|
190
190
|
index: number;
|
|
191
191
|
element: number;
|
|
192
192
|
field: string;
|
|
193
|
+
} | {
|
|
194
|
+
kind: 'call_arg_scaled';
|
|
195
|
+
index: number;
|
|
196
|
+
num: string;
|
|
197
|
+
den: string;
|
|
193
198
|
} | {
|
|
194
199
|
kind: 'amount';
|
|
195
200
|
token: string;
|
|
@@ -207,6 +212,10 @@ export type PredicateLeaf = {
|
|
|
207
212
|
} | {
|
|
208
213
|
kind: 'oracle_price';
|
|
209
214
|
asset: string;
|
|
215
|
+
} | {
|
|
216
|
+
kind: 'oracle_threshold';
|
|
217
|
+
value: string;
|
|
218
|
+
decimals: number;
|
|
210
219
|
} | {
|
|
211
220
|
kind: 'literal_address';
|
|
212
221
|
value: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
|
|
6
6
|
"type": "module",
|
|
@@ -67,11 +67,14 @@
|
|
|
67
67
|
"prepack": "bun run build"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@stellar/stellar-sdk": "14.4.0",
|
|
71
70
|
"zod": "3.25.76"
|
|
72
71
|
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@stellar/stellar-sdk": "^14.4.0"
|
|
74
|
+
},
|
|
73
75
|
"devDependencies": {
|
|
74
76
|
"@biomejs/biome": "2.5.5",
|
|
77
|
+
"@stellar/stellar-sdk": "14.4.0",
|
|
75
78
|
"@types/node": "^26.1.1",
|
|
76
79
|
"typescript": "5.9.3"
|
|
77
80
|
}
|
|
@@ -90,7 +90,10 @@ export interface InterpreterAdapterConfig {
|
|
|
90
90
|
const CAPABILITIES: CustodyCapabilities = {
|
|
91
91
|
supportsSpendWindow: true,
|
|
92
92
|
supportsThreshold: false, // thresholds are the OZ adapter's job
|
|
93
|
-
|
|
93
|
+
// False for the predicate-leaf path: the interpreter refuses a `valid_until`
|
|
94
|
+
// leaf at install. Expiry is still available, but as the context rule's
|
|
95
|
+
// validUntilLedger, which the smart account enforces - not as a predicate.
|
|
96
|
+
supportsTimeExpiry: false,
|
|
94
97
|
supportsOraclePrice: true,
|
|
95
98
|
supportsInvocationCount: true,
|
|
96
99
|
supportsGeneralPredicate: true,
|
|
@@ -296,24 +299,64 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
|
|
|
296
299
|
function unsupportedConstruct(cond: IRCondition): string | null {
|
|
297
300
|
switch (cond.op) {
|
|
298
301
|
case 'in':
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
+
// The oracle position rule is a hard error and must be raised before a
|
|
303
|
+
// construct can be written off as uncovered - otherwise a subtree that
|
|
304
|
+
// is BOTH misplaced-oracle and unsourceable would be silently dropped
|
|
305
|
+
// instead of rejected.
|
|
306
|
+
assertNoOracleDescendants(cond)
|
|
307
|
+
return (
|
|
308
|
+
unsourceableSelector(cond.selector) ??
|
|
309
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
310
|
+
? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
|
|
311
|
+
: null)
|
|
312
|
+
)
|
|
302
313
|
case 'eq_seq':
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
314
|
+
return (
|
|
315
|
+
unsourceableSelector(cond.selector) ??
|
|
316
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
317
|
+
? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
|
|
318
|
+
: null)
|
|
319
|
+
)
|
|
306
320
|
case 'compare': {
|
|
307
321
|
const s = cond.compare.selector
|
|
308
322
|
if (s.kind === 'calldata') return 'EVM calldata comparison (predicate DSL)'
|
|
309
323
|
if (s.kind === 'value') return 'tx.value comparison (predicate DSL)'
|
|
310
|
-
|
|
324
|
+
// The on-chain interpreter sees ONE authorized call - there is no
|
|
325
|
+
// `Context.sub_invocations` in v1 - so it cannot observe the
|
|
326
|
+
// transaction's token movements. `amount` has no value to read, and
|
|
327
|
+
// `window_spent` accumulates BY that amount, so its counter would never
|
|
328
|
+
// move. Deriving either from the call payload would quietly swap "value
|
|
329
|
+
// actually moved" for "value the caller declared" - a weaker guarantee
|
|
330
|
+
// than the review card would be claiming.
|
|
331
|
+
//
|
|
332
|
+
// Rolling spend caps belong to the OZ `spending_limit` primitive, which
|
|
333
|
+
// is already audited and which the OZ adapter emits. A per-call cap is
|
|
334
|
+
// expressible here as `arg_field`; bounding it with `invocation_count`
|
|
335
|
+
// gives an enforceable ceiling per window.
|
|
336
|
+
return unsourceableSelector(s)
|
|
311
337
|
}
|
|
338
|
+
// Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
|
|
339
|
+
// pre-scan, which only sees top-level constraints.
|
|
312
340
|
case 'and':
|
|
341
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
|
|
313
342
|
case 'or':
|
|
343
|
+
assertNoOracleDescendants(cond)
|
|
344
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null
|
|
314
345
|
case 'not':
|
|
315
|
-
|
|
346
|
+
assertNoOracleDescendants(cond)
|
|
347
|
+
return unsupportedConstruct(cond.child)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Selectors whose value the on-chain interpreter has no way to obtain. */
|
|
352
|
+
function unsourceableSelector(s: IRSelector): string | null {
|
|
353
|
+
if (s.kind === 'amount') {
|
|
354
|
+
return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`
|
|
355
|
+
}
|
|
356
|
+
if (s.kind === 'window_spent') {
|
|
357
|
+
return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`
|
|
316
358
|
}
|
|
359
|
+
return null
|
|
317
360
|
}
|
|
318
361
|
|
|
319
362
|
/** Lower one IR condition to a PredicateNode. Enforces the oracle position
|
|
@@ -393,10 +436,15 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
|
|
|
393
436
|
return { kind: 'call_arg_len', index: s.argIndex }
|
|
394
437
|
case 'arg_field':
|
|
395
438
|
return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field }
|
|
439
|
+
// `amount` / `window_spent` are filtered out by `unsupportedConstruct`
|
|
440
|
+
// before lowering - the interpreter cannot source either on chain. Reaching
|
|
441
|
+
// here means the pre-scan was bypassed, so fail loudly rather than emit a
|
|
442
|
+
// leaf the contract will refuse.
|
|
396
443
|
case 'amount':
|
|
397
|
-
return { kind: 'amount', token: s.token }
|
|
398
444
|
case 'window_spent':
|
|
399
|
-
|
|
445
|
+
throw new Error(
|
|
446
|
+
`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`
|
|
447
|
+
)
|
|
400
448
|
case 'invocation_count':
|
|
401
449
|
return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds }
|
|
402
450
|
case 'now':
|
|
@@ -427,6 +475,19 @@ function lowerSelector(s: IRSelector): PredicateLeaf {
|
|
|
427
475
|
* - invocation_count -> u32 (counts are small non-negative integers)
|
|
428
476
|
* - now / valid_until -> u64 (unix timestamps in seconds) */
|
|
429
477
|
function literalFromIRCompare(c: IRCompare): PredicateLeaf {
|
|
478
|
+
// An oracle comparand is not a bare literal: the interpreter refuses one,
|
|
479
|
+
// because assuming it shares the normalised 9-dp basis is what made a raw
|
|
480
|
+
// 14-dp threshold permit everything it was written to deny.
|
|
481
|
+
if (c.selector.kind === 'oracle_price') {
|
|
482
|
+
if (c.valueDecimals === undefined) {
|
|
483
|
+
throw toolError(
|
|
484
|
+
'ORACLE_THRESHOLD_BASIS_REQUIRED',
|
|
485
|
+
'An oracle price bound must declare the decimal basis of its threshold ' +
|
|
486
|
+
'(oracle prices normalise to 9 decimals).'
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals }
|
|
490
|
+
}
|
|
430
491
|
const scalarType: IRScalarType = selectorScalarType(c.selector)
|
|
431
492
|
return literalFromScalar(c.value, scalarType)
|
|
432
493
|
}
|
|
@@ -113,15 +113,40 @@ pub fn add_context_rule(
|
|
|
113
113
|
- `add_context_rule` is called by the **smart account itself** (the contract owning the context rules).
|
|
114
114
|
- Auth is enforced by Soroban's `require_auth` on the calling context — the smart account's master signer (or a delegated signer with sufficient weight) must authorize the call.
|
|
115
115
|
- The interpreter's `install` function is called **within the same transaction** as `add_context_rule` and receives the same `smart_account` address.
|
|
116
|
-
-
|
|
116
|
+
- The interpreter **cannot** read the smart account's master set; it receives only the already-authorized `ContextRule` and `smart_account` address.
|
|
117
|
+
|
|
118
|
+
> **CORRECTED 2026-07-27 - do not read the line above as a safety guarantee.**
|
|
119
|
+
> An earlier version of this file said the interpreter "does NOT need to" read the
|
|
120
|
+
> master set because the master authorizes `add_context_rule` directly. The master
|
|
121
|
+
> *can* authorize it. It is **not** true that only the master can.
|
|
122
|
+
>
|
|
123
|
+
> Verified at this pinned commit: `storage.rs` states `add_policy` /
|
|
124
|
+
> `add_context_rule` modify storage "without requiring authorization. Ensure proper
|
|
125
|
+
> access control is implemented at the contract level"; `mod.rs:439` and `mod.rs:246`
|
|
126
|
+
> implement that as `e.current_contract_address().require_auth()`, a self-auth
|
|
127
|
+
> resolved through the account's own context rules; and `mod.rs:80` states that
|
|
128
|
+
> "a `Default` rule matches any context".
|
|
129
|
+
>
|
|
130
|
+
> Therefore any signer on a `Default` rule can attach or replace a policy - including
|
|
131
|
+
> a delegated agent, on its own rule. See `plans/security-finding-install-authorization.md`.
|
|
117
132
|
|
|
118
133
|
### Safe mechanism for master-only install
|
|
119
134
|
|
|
120
|
-
|
|
135
|
+
Two-step install is the install **path**. It is NOT relied on as the authorization
|
|
136
|
+
**guarantee**, because the auth it inherits is whatever the account's own rules permit:
|
|
137
|
+
|
|
121
138
|
1. Owner calls `add_context_rule` with the interpreter address + initial rule.
|
|
122
|
-
2. Interpreter's `install` is called within the same auth context
|
|
139
|
+
2. Interpreter's `install` is called within the same auth context.
|
|
123
140
|
3. Re-install is structurally possible via `install_nonce` (stored nonce + 1 check).
|
|
124
141
|
|
|
142
|
+
The guarantee comes from the interpreter pinning its own authorized installer set at
|
|
143
|
+
first install for `(smart_account, rule_id)`, and denying later installs and
|
|
144
|
+
uninstalls from outside that set with `MASTER_AUTH_REQUIRED`. Rotation goes through
|
|
145
|
+
`rotate_master_signer_set(new_set)`, gated by the old set.
|
|
146
|
+
|
|
147
|
+
`install_nonce` alone does not close this: it blocks replay of an *old* document, but
|
|
148
|
+
a *fresh* looser document with `nonce + 1` would otherwise be accepted.
|
|
149
|
+
|
|
125
150
|
---
|
|
126
151
|
|
|
127
152
|
## Framework Limits (OZ Hard Limits)
|
package/src/errors.ts
CHANGED
|
@@ -39,9 +39,20 @@ export type ErrorCode =
|
|
|
39
39
|
| 'AMOUNT_OVERFLOW'
|
|
40
40
|
| 'RULE_SIGNERS_CHANGED'
|
|
41
41
|
| 'SCOPE_SELF_CALL'
|
|
42
|
+
| 'ARG_MISMATCH'
|
|
43
|
+
| 'CONTRACT_SCOPE'
|
|
44
|
+
| 'UNSUPPORTED_NODE'
|
|
45
|
+
| 'STATEFUL_BOUND'
|
|
46
|
+
| 'NOT_IN_ALLOWLIST'
|
|
47
|
+
| 'FREQUENCY'
|
|
48
|
+
// A `call_arg >= call_arg_scaled(..)` slippage floor was not met. Distinct
|
|
49
|
+
// from the generic ARG_MISMATCH/STATEFUL_BOUND so a violated swap floor
|
|
50
|
+
// reads as itself on the review card.
|
|
51
|
+
| 'SLIPPAGE_FLOOR'
|
|
42
52
|
// --- oracle denies (fatal evaluator errors) ---
|
|
43
53
|
| 'ORACLE_STALE'
|
|
44
54
|
| 'ORACLE_MISSING'
|
|
55
|
+
| 'ORACLE_NO_CONFIRMATION'
|
|
45
56
|
| 'ORACLE_DEVIATION_EXCEEDED'
|
|
46
57
|
| 'ORACLE_MALFORMED_HISTORY'
|
|
47
58
|
| 'ORACLE_FINGERPRINT_DRIFT'
|
|
@@ -49,6 +60,8 @@ export type ErrorCode =
|
|
|
49
60
|
| 'ORACLE_LEAF_INVALID_POSITION'
|
|
50
61
|
| 'ORACLE_PARAMS_OUT_OF_RANGE'
|
|
51
62
|
| 'ORACLE_DECIMALS_MISMATCH'
|
|
63
|
+
| 'ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE'
|
|
64
|
+
| 'ORACLE_THRESHOLD_BASIS_REQUIRED'
|
|
52
65
|
// --- escape-hatch compile gate ---
|
|
53
66
|
| 'COMPILE_OK'
|
|
54
67
|
| 'COMPILE_GATE_FAILED'
|