@crediolabs/policy-synth 0.1.9 → 0.1.10
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/review-card/builder.js +30 -0
- package/dist/review-card/cross-check.js +38 -0
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +12 -0
- package/dist/run/schemas.js +14 -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 +66 -1
- package/dist-cjs/review-card/builder.js +30 -0
- package/dist-cjs/review-card/cross-check.js +38 -0
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +12 -0
- package/dist-cjs/run/schemas.js +14 -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 +66 -1
- package/package.json +1 -1
- package/src/review-card/builder.ts +26 -0
- package/src/review-card/cross-check.ts +38 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +103 -4
|
@@ -138,6 +138,36 @@ function renderComparison(node) {
|
|
|
138
138
|
if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
|
|
139
139
|
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
140
140
|
}
|
|
141
|
+
// eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
|
|
142
|
+
// Binds the OUTER vec length so a caller cannot append an element to
|
|
143
|
+
// defeat a per-element pin. Rendered as a single readable line so the
|
|
144
|
+
// review card surfaces the implicit "no new elements" guarantee.
|
|
145
|
+
if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
|
|
146
|
+
return `Length of arg[${left.index}] is ${right.value}`;
|
|
147
|
+
}
|
|
148
|
+
// eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
|
|
149
|
+
// The structured-argument bind pins a scalar field inside a vec element.
|
|
150
|
+
// For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
|
|
151
|
+
// is visible; eq reads "= <value>".
|
|
152
|
+
if (left.kind === 'call_arg_field') {
|
|
153
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
154
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
155
|
+
if (right.kind === 'literal_address')
|
|
156
|
+
return `${head} ${sep} ${right.value}`;
|
|
157
|
+
if (right.kind === 'literal_symbol')
|
|
158
|
+
return `${head} ${sep} ${right.value}`;
|
|
159
|
+
if (right.kind === 'literal_bytes')
|
|
160
|
+
return `${head} ${sep} ${right.value}`;
|
|
161
|
+
if (right.kind === 'literal_u64')
|
|
162
|
+
return `${head} ${sep} ${right.value}`;
|
|
163
|
+
if (right.kind === 'literal_i128')
|
|
164
|
+
return `${head} ${sep} ${right.value}`;
|
|
165
|
+
if (right.kind === 'literal_u32')
|
|
166
|
+
return `${head} ${sep} ${right.value}`;
|
|
167
|
+
if (right.kind === 'literal_vec') {
|
|
168
|
+
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
141
171
|
// invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
142
172
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
143
173
|
return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
|
|
@@ -67,6 +67,44 @@ function pushComparison(left, right, op, out) {
|
|
|
67
67
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
71
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
75
|
+
// the cross-check can assert a faithful summary.
|
|
76
|
+
if (left.kind === 'call_arg_field') {
|
|
77
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
78
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
79
|
+
if (right.kind === 'literal_address') {
|
|
80
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (right.kind === 'literal_symbol') {
|
|
84
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (right.kind === 'literal_bytes') {
|
|
88
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (right.kind === 'literal_u64') {
|
|
92
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (right.kind === 'literal_i128') {
|
|
96
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (right.kind === 'literal_u32') {
|
|
100
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (right.kind === 'literal_vec') {
|
|
104
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
70
108
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
71
109
|
out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
|
|
72
110
|
return;
|
package/dist/run/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
1
|
+
import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
2
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
2
3
|
import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
|
|
3
4
|
export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
|
|
4
5
|
export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
|
|
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
|
|
|
28
29
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
29
30
|
* XDR decode error in the adapter) would otherwise surface as
|
|
30
31
|
* "[object Object]" in the MCP transport. */
|
|
31
|
-
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy
|
|
32
|
+
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
|
|
33
|
+
explain?: {
|
|
34
|
+
predicateTree: PredicateNode | null;
|
|
35
|
+
simulation: SimulationResult;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
32
38
|
/** Build a canonical ToolError for a thrown exception caught by the tool
|
|
33
39
|
* envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
|
|
34
40
|
* default, so we extract a string-friendly message and tag the original
|
package/dist/run/index.js
CHANGED
|
@@ -89,7 +89,7 @@ export async function runSynthesizePolicy(raw) {
|
|
|
89
89
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
90
90
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
91
91
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
92
|
-
return await synthesizeFromMandate(input.mandate, ozConfig);
|
|
92
|
+
return await synthesizeFromMandate(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
|
|
93
93
|
}
|
|
94
94
|
// recording source
|
|
95
95
|
const recorded = input.recordedTx;
|
|
@@ -100,6 +100,7 @@ export async function runSynthesizePolicy(raw) {
|
|
|
100
100
|
? { confidenceOverride: input.confidenceOverride }
|
|
101
101
|
: {}),
|
|
102
102
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
103
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
103
104
|
}, ozConfig);
|
|
104
105
|
}
|
|
105
106
|
catch (e) {
|
package/dist/run/schemas.d.ts
CHANGED
|
@@ -648,6 +648,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
648
648
|
weighted_threshold: string;
|
|
649
649
|
};
|
|
650
650
|
}>>;
|
|
651
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
651
652
|
}, "strip", z.ZodTypeAny, {
|
|
652
653
|
source: "mandate";
|
|
653
654
|
mandate: {
|
|
@@ -676,6 +677,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
676
677
|
weighted_threshold: string;
|
|
677
678
|
};
|
|
678
679
|
} | undefined;
|
|
680
|
+
explain?: boolean | undefined;
|
|
679
681
|
}, {
|
|
680
682
|
source: "mandate";
|
|
681
683
|
mandate: {
|
|
@@ -704,6 +706,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
704
706
|
weighted_threshold: string;
|
|
705
707
|
};
|
|
706
708
|
} | undefined;
|
|
709
|
+
explain?: boolean | undefined;
|
|
707
710
|
}>;
|
|
708
711
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
709
712
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -1085,6 +1088,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1085
1088
|
weighted_threshold: string;
|
|
1086
1089
|
};
|
|
1087
1090
|
}>>;
|
|
1091
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1088
1092
|
}, "strip", z.ZodTypeAny, {
|
|
1089
1093
|
network: "mainnet" | "testnet";
|
|
1090
1094
|
source: "recording";
|
|
@@ -1149,6 +1153,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1149
1153
|
weighted_threshold: string;
|
|
1150
1154
|
};
|
|
1151
1155
|
} | undefined;
|
|
1156
|
+
explain?: boolean | undefined;
|
|
1152
1157
|
}, {
|
|
1153
1158
|
network: "mainnet" | "testnet";
|
|
1154
1159
|
source: "recording";
|
|
@@ -1213,6 +1218,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1213
1218
|
weighted_threshold: string;
|
|
1214
1219
|
};
|
|
1215
1220
|
} | undefined;
|
|
1221
|
+
explain?: boolean | undefined;
|
|
1216
1222
|
}>;
|
|
1217
1223
|
export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
|
|
1218
1224
|
source: z.ZodLiteral<"mandate">;
|
|
@@ -1334,6 +1340,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1334
1340
|
weighted_threshold: string;
|
|
1335
1341
|
};
|
|
1336
1342
|
}>>;
|
|
1343
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1337
1344
|
}, "strip", z.ZodTypeAny, {
|
|
1338
1345
|
source: "mandate";
|
|
1339
1346
|
mandate: {
|
|
@@ -1362,6 +1369,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1362
1369
|
weighted_threshold: string;
|
|
1363
1370
|
};
|
|
1364
1371
|
} | undefined;
|
|
1372
|
+
explain?: boolean | undefined;
|
|
1365
1373
|
}, {
|
|
1366
1374
|
source: "mandate";
|
|
1367
1375
|
mandate: {
|
|
@@ -1390,6 +1398,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1390
1398
|
weighted_threshold: string;
|
|
1391
1399
|
};
|
|
1392
1400
|
} | undefined;
|
|
1401
|
+
explain?: boolean | undefined;
|
|
1393
1402
|
}>, z.ZodObject<{
|
|
1394
1403
|
source: z.ZodLiteral<"recording">;
|
|
1395
1404
|
recordedTx: z.ZodObject<{
|
|
@@ -1736,6 +1745,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1736
1745
|
weighted_threshold: string;
|
|
1737
1746
|
};
|
|
1738
1747
|
}>>;
|
|
1748
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1739
1749
|
}, "strip", z.ZodTypeAny, {
|
|
1740
1750
|
network: "mainnet" | "testnet";
|
|
1741
1751
|
source: "recording";
|
|
@@ -1800,6 +1810,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1800
1810
|
weighted_threshold: string;
|
|
1801
1811
|
};
|
|
1802
1812
|
} | undefined;
|
|
1813
|
+
explain?: boolean | undefined;
|
|
1803
1814
|
}, {
|
|
1804
1815
|
network: "mainnet" | "testnet";
|
|
1805
1816
|
source: "recording";
|
|
@@ -1864,6 +1875,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1864
1875
|
weighted_threshold: string;
|
|
1865
1876
|
};
|
|
1866
1877
|
} | undefined;
|
|
1878
|
+
explain?: boolean | undefined;
|
|
1867
1879
|
}>]>;
|
|
1868
1880
|
export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>;
|
|
1869
1881
|
export declare const ToolErrorSchema: z.ZodObject<{
|
package/dist/run/schemas.js
CHANGED
|
@@ -175,6 +175,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
|
|
|
175
175
|
source: z.literal('mandate'),
|
|
176
176
|
mandate: MandateSpecSchema,
|
|
177
177
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
178
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
179
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
180
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
181
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
182
|
+
explain: z.boolean().optional(),
|
|
178
183
|
});
|
|
179
184
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
180
185
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -200,6 +205,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
|
200
205
|
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
201
206
|
interpreter: InterpreterOptionsSchema.optional(),
|
|
202
207
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
208
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
209
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
210
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
211
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
212
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
213
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
214
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
215
|
+
// explain.
|
|
216
|
+
explain: z.boolean().optional(),
|
|
203
217
|
});
|
|
204
218
|
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
205
219
|
SynthesizePolicyMandateInputSchema,
|
|
@@ -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
|
+
};
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
import { createOzAdapter } from "../adapters/oz/adapter.js";
|
|
11
11
|
import { mandateToPolicyIR } from "../mandate/to-ir.js";
|
|
12
12
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
13
|
-
export function synthesizeFromMandate(spec, ozConfig
|
|
13
|
+
export function synthesizeFromMandate(spec, ozConfig,
|
|
14
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
15
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
16
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
17
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
18
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
19
|
+
* altered. */
|
|
20
|
+
opts) {
|
|
14
21
|
const ir = mandateToPolicyIR(spec);
|
|
15
22
|
const adapter = createOzAdapter(ozConfig);
|
|
16
23
|
const result = adapter.compile(ir);
|
|
@@ -30,5 +37,23 @@ export function synthesizeFromMandate(spec, ozConfig) {
|
|
|
30
37
|
...result.proposed,
|
|
31
38
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
32
39
|
};
|
|
33
|
-
|
|
40
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
41
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
42
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
43
|
+
const envelope = { ok: true, data: proposed };
|
|
44
|
+
if (opts?.explain) {
|
|
45
|
+
envelope.explain = {
|
|
46
|
+
predicateTree: null,
|
|
47
|
+
simulation: {
|
|
48
|
+
permit: {
|
|
49
|
+
tx: 'deny',
|
|
50
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
51
|
+
},
|
|
52
|
+
evaluatedCases: [],
|
|
53
|
+
backend: 'ts-model',
|
|
54
|
+
simulatorVersion: 'not-run',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return envelope;
|
|
34
59
|
}
|
|
@@ -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;
|
|
@@ -195,6 +195,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
195
195
|
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
196
196
|
};
|
|
197
197
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
|
|
198
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
199
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
200
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
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.
|
|
204
|
+
let explain = null;
|
|
205
|
+
let explainSim = null;
|
|
198
206
|
// 5. OZ compile (always runs).
|
|
199
207
|
const ozAdapter = createOzAdapter(ozConfig);
|
|
200
208
|
const compileRes = ozAdapter.compile(composed.ir);
|
|
@@ -372,6 +380,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
372
380
|
},
|
|
373
381
|
};
|
|
374
382
|
}
|
|
383
|
+
// --explain capture: the interpreter path already produced the real
|
|
384
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
385
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
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.
|
|
389
|
+
if (opts.explain) {
|
|
390
|
+
explain = finalPredicate;
|
|
391
|
+
const evaluatedCases = [
|
|
392
|
+
{
|
|
393
|
+
dimension: 'permit',
|
|
394
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
395
|
+
reason: 'matches recorded call',
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
for (const deny of harnessCases.denies) {
|
|
399
|
+
const r = evaluate(finalPredicate, deny.ctx);
|
|
400
|
+
evaluatedCases.push({
|
|
401
|
+
dimension: deny.dimension,
|
|
402
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
403
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
explainSim = {
|
|
407
|
+
permit: { tx: 'permit' },
|
|
408
|
+
evaluatedCases,
|
|
409
|
+
backend: 'ts-model',
|
|
410
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
375
413
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
376
414
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
377
415
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -469,7 +507,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
469
507
|
],
|
|
470
508
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
471
509
|
};
|
|
472
|
-
|
|
510
|
+
// --explain success envelope. The interpreter path populated
|
|
511
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
512
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
513
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
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.
|
|
517
|
+
const envelope = { ok: true, data: proposed };
|
|
518
|
+
if (opts.explain) {
|
|
519
|
+
if (explainSim) {
|
|
520
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim };
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
envelope.explain = {
|
|
524
|
+
predicateTree: null,
|
|
525
|
+
simulation: {
|
|
526
|
+
permit: {
|
|
527
|
+
tx: 'deny',
|
|
528
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
529
|
+
},
|
|
530
|
+
evaluatedCases: [],
|
|
531
|
+
backend: 'ts-model',
|
|
532
|
+
simulatorVersion: 'not-run',
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return envelope;
|
|
473
538
|
}
|
|
474
539
|
// Re-export the throwToolError helper for callers that need to surface a
|
|
475
540
|
// ToolError-shaped throw inside the body (e.g. tests).
|
|
@@ -141,6 +141,36 @@ function renderComparison(node) {
|
|
|
141
141
|
if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
|
|
142
142
|
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
143
143
|
}
|
|
144
|
+
// eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
|
|
145
|
+
// Binds the OUTER vec length so a caller cannot append an element to
|
|
146
|
+
// defeat a per-element pin. Rendered as a single readable line so the
|
|
147
|
+
// review card surfaces the implicit "no new elements" guarantee.
|
|
148
|
+
if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
|
|
149
|
+
return `Length of arg[${left.index}] is ${right.value}`;
|
|
150
|
+
}
|
|
151
|
+
// eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
|
|
152
|
+
// The structured-argument bind pins a scalar field inside a vec element.
|
|
153
|
+
// For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
|
|
154
|
+
// is visible; eq reads "= <value>".
|
|
155
|
+
if (left.kind === 'call_arg_field') {
|
|
156
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
157
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
158
|
+
if (right.kind === 'literal_address')
|
|
159
|
+
return `${head} ${sep} ${right.value}`;
|
|
160
|
+
if (right.kind === 'literal_symbol')
|
|
161
|
+
return `${head} ${sep} ${right.value}`;
|
|
162
|
+
if (right.kind === 'literal_bytes')
|
|
163
|
+
return `${head} ${sep} ${right.value}`;
|
|
164
|
+
if (right.kind === 'literal_u64')
|
|
165
|
+
return `${head} ${sep} ${right.value}`;
|
|
166
|
+
if (right.kind === 'literal_i128')
|
|
167
|
+
return `${head} ${sep} ${right.value}`;
|
|
168
|
+
if (right.kind === 'literal_u32')
|
|
169
|
+
return `${head} ${sep} ${right.value}`;
|
|
170
|
+
if (right.kind === 'literal_vec') {
|
|
171
|
+
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
144
174
|
// invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
145
175
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
146
176
|
return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
|
|
@@ -70,6 +70,44 @@ function pushComparison(left, right, op, out) {
|
|
|
70
70
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
74
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
78
|
+
// the cross-check can assert a faithful summary.
|
|
79
|
+
if (left.kind === 'call_arg_field') {
|
|
80
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`;
|
|
81
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op);
|
|
82
|
+
if (right.kind === 'literal_address') {
|
|
83
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (right.kind === 'literal_symbol') {
|
|
87
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (right.kind === 'literal_bytes') {
|
|
91
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (right.kind === 'literal_u64') {
|
|
95
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (right.kind === 'literal_i128') {
|
|
99
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (right.kind === 'literal_u32') {
|
|
103
|
+
out.push(`${head} ${sep} ${right.value}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (right.kind === 'literal_vec') {
|
|
107
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
73
111
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
74
112
|
out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
|
|
75
113
|
return;
|
package/dist-cjs/run/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
1
|
+
import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
|
|
2
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
2
3
|
import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
|
|
3
4
|
export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
|
|
4
5
|
export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
|
|
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
|
|
|
28
29
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
29
30
|
* XDR decode error in the adapter) would otherwise surface as
|
|
30
31
|
* "[object Object]" in the MCP transport. */
|
|
31
|
-
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy
|
|
32
|
+
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
|
|
33
|
+
explain?: {
|
|
34
|
+
predicateTree: PredicateNode | null;
|
|
35
|
+
simulation: SimulationResult;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
32
38
|
/** Build a canonical ToolError for a thrown exception caught by the tool
|
|
33
39
|
* envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
|
|
34
40
|
* default, so we extract a string-friendly message and tag the original
|
package/dist-cjs/run/index.js
CHANGED
|
@@ -104,7 +104,7 @@ async function runSynthesizePolicy(raw) {
|
|
|
104
104
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
105
105
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
106
106
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
107
|
-
return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig);
|
|
107
|
+
return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
|
|
108
108
|
}
|
|
109
109
|
// recording source
|
|
110
110
|
const recorded = input.recordedTx;
|
|
@@ -115,6 +115,7 @@ async function runSynthesizePolicy(raw) {
|
|
|
115
115
|
? { confidenceOverride: input.confidenceOverride }
|
|
116
116
|
: {}),
|
|
117
117
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
118
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
118
119
|
}, ozConfig);
|
|
119
120
|
}
|
|
120
121
|
catch (e) {
|
|
@@ -648,6 +648,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
648
648
|
weighted_threshold: string;
|
|
649
649
|
};
|
|
650
650
|
}>>;
|
|
651
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
651
652
|
}, "strip", z.ZodTypeAny, {
|
|
652
653
|
source: "mandate";
|
|
653
654
|
mandate: {
|
|
@@ -676,6 +677,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
676
677
|
weighted_threshold: string;
|
|
677
678
|
};
|
|
678
679
|
} | undefined;
|
|
680
|
+
explain?: boolean | undefined;
|
|
679
681
|
}, {
|
|
680
682
|
source: "mandate";
|
|
681
683
|
mandate: {
|
|
@@ -704,6 +706,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
|
|
|
704
706
|
weighted_threshold: string;
|
|
705
707
|
};
|
|
706
708
|
} | undefined;
|
|
709
|
+
explain?: boolean | undefined;
|
|
707
710
|
}>;
|
|
708
711
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
709
712
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -1085,6 +1088,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1085
1088
|
weighted_threshold: string;
|
|
1086
1089
|
};
|
|
1087
1090
|
}>>;
|
|
1091
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1088
1092
|
}, "strip", z.ZodTypeAny, {
|
|
1089
1093
|
network: "mainnet" | "testnet";
|
|
1090
1094
|
source: "recording";
|
|
@@ -1149,6 +1153,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1149
1153
|
weighted_threshold: string;
|
|
1150
1154
|
};
|
|
1151
1155
|
} | undefined;
|
|
1156
|
+
explain?: boolean | undefined;
|
|
1152
1157
|
}, {
|
|
1153
1158
|
network: "mainnet" | "testnet";
|
|
1154
1159
|
source: "recording";
|
|
@@ -1213,6 +1218,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
|
|
|
1213
1218
|
weighted_threshold: string;
|
|
1214
1219
|
};
|
|
1215
1220
|
} | undefined;
|
|
1221
|
+
explain?: boolean | undefined;
|
|
1216
1222
|
}>;
|
|
1217
1223
|
export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
|
|
1218
1224
|
source: z.ZodLiteral<"mandate">;
|
|
@@ -1334,6 +1340,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1334
1340
|
weighted_threshold: string;
|
|
1335
1341
|
};
|
|
1336
1342
|
}>>;
|
|
1343
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1337
1344
|
}, "strip", z.ZodTypeAny, {
|
|
1338
1345
|
source: "mandate";
|
|
1339
1346
|
mandate: {
|
|
@@ -1362,6 +1369,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1362
1369
|
weighted_threshold: string;
|
|
1363
1370
|
};
|
|
1364
1371
|
} | undefined;
|
|
1372
|
+
explain?: boolean | undefined;
|
|
1365
1373
|
}, {
|
|
1366
1374
|
source: "mandate";
|
|
1367
1375
|
mandate: {
|
|
@@ -1390,6 +1398,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1390
1398
|
weighted_threshold: string;
|
|
1391
1399
|
};
|
|
1392
1400
|
} | undefined;
|
|
1401
|
+
explain?: boolean | undefined;
|
|
1393
1402
|
}>, z.ZodObject<{
|
|
1394
1403
|
source: z.ZodLiteral<"recording">;
|
|
1395
1404
|
recordedTx: z.ZodObject<{
|
|
@@ -1736,6 +1745,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1736
1745
|
weighted_threshold: string;
|
|
1737
1746
|
};
|
|
1738
1747
|
}>>;
|
|
1748
|
+
explain: z.ZodOptional<z.ZodBoolean>;
|
|
1739
1749
|
}, "strip", z.ZodTypeAny, {
|
|
1740
1750
|
network: "mainnet" | "testnet";
|
|
1741
1751
|
source: "recording";
|
|
@@ -1800,6 +1810,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1800
1810
|
weighted_threshold: string;
|
|
1801
1811
|
};
|
|
1802
1812
|
} | undefined;
|
|
1813
|
+
explain?: boolean | undefined;
|
|
1803
1814
|
}, {
|
|
1804
1815
|
network: "mainnet" | "testnet";
|
|
1805
1816
|
source: "recording";
|
|
@@ -1864,6 +1875,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
|
|
|
1864
1875
|
weighted_threshold: string;
|
|
1865
1876
|
};
|
|
1866
1877
|
} | undefined;
|
|
1878
|
+
explain?: boolean | undefined;
|
|
1867
1879
|
}>]>;
|
|
1868
1880
|
export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>;
|
|
1869
1881
|
export declare const ToolErrorSchema: z.ZodObject<{
|
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -178,6 +178,11 @@ exports.SynthesizePolicyMandateInputSchema = zod_1.z.object({
|
|
|
178
178
|
source: zod_1.z.literal('mandate'),
|
|
179
179
|
mandate: exports.MandateSpecSchema,
|
|
180
180
|
ozConfig: exports.OzAdapterConfigSchema.optional(),
|
|
181
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
182
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
183
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
184
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
185
|
+
explain: zod_1.z.boolean().optional(),
|
|
181
186
|
});
|
|
182
187
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
183
188
|
* express (per-method scoping, invocation-count windows, oracle bounds, exact
|
|
@@ -203,6 +208,15 @@ exports.SynthesizePolicyRecordingInputSchema = zod_1.z.object({
|
|
|
203
208
|
confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
|
|
204
209
|
interpreter: exports.InterpreterOptionsSchema.optional(),
|
|
205
210
|
ozConfig: exports.OzAdapterConfigSchema.optional(),
|
|
211
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
212
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
213
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
214
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
215
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
216
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
217
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
218
|
+
// explain.
|
|
219
|
+
explain: zod_1.z.boolean().optional(),
|
|
206
220
|
});
|
|
207
221
|
exports.SynthesizePolicyInputSchema = zod_1.z.discriminatedUnion('source', [
|
|
208
222
|
exports.SynthesizePolicyMandateInputSchema,
|
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
|
@@ -171,6 +171,32 @@ function renderComparison(
|
|
|
171
171
|
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
|
|
175
|
+
// Binds the OUTER vec length so a caller cannot append an element to
|
|
176
|
+
// defeat a per-element pin. Rendered as a single readable line so the
|
|
177
|
+
// review card surfaces the implicit "no new elements" guarantee.
|
|
178
|
+
if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
|
|
179
|
+
return `Length of arg[${left.index}] is ${right.value}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
|
|
183
|
+
// The structured-argument bind pins a scalar field inside a vec element.
|
|
184
|
+
// For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
|
|
185
|
+
// is visible; eq reads "= <value>".
|
|
186
|
+
if (left.kind === 'call_arg_field') {
|
|
187
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`
|
|
188
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
|
|
189
|
+
if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
|
|
190
|
+
if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
|
|
191
|
+
if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
|
|
192
|
+
if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
|
|
193
|
+
if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
|
|
194
|
+
if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
|
|
195
|
+
if (right.kind === 'literal_vec') {
|
|
196
|
+
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
// invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
175
201
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
176
202
|
return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
|
|
@@ -80,6 +80,44 @@ function pushComparison(
|
|
|
80
80
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
84
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
88
|
+
// the cross-check can assert a faithful summary.
|
|
89
|
+
if (left.kind === 'call_arg_field') {
|
|
90
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`
|
|
91
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
92
|
+
if (right.kind === 'literal_address') {
|
|
93
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (right.kind === 'literal_symbol') {
|
|
97
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (right.kind === 'literal_bytes') {
|
|
101
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (right.kind === 'literal_u64') {
|
|
105
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (right.kind === 'literal_i128') {
|
|
109
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (right.kind === 'literal_u32') {
|
|
113
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (right.kind === 'literal_vec') {
|
|
117
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
}
|
|
83
121
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
84
122
|
out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`)
|
|
85
123
|
return
|
package/src/run/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type ErrorCode,
|
|
22
22
|
type MandateSpec,
|
|
23
23
|
type OzAdapterConfig,
|
|
24
|
+
type PredicateNode,
|
|
24
25
|
type ProposedPolicy,
|
|
25
26
|
placeholderOzConfig,
|
|
26
27
|
type RecordedTransaction,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type ToolError,
|
|
32
33
|
type ToolResponse,
|
|
33
34
|
} from '../index.ts'
|
|
35
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
34
36
|
import {
|
|
35
37
|
type RecordTransactionInput,
|
|
36
38
|
RecordTransactionInputSchema,
|
|
@@ -112,7 +114,14 @@ export async function runRecordTransaction(
|
|
|
112
114
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
113
115
|
* XDR decode error in the adapter) would otherwise surface as
|
|
114
116
|
* "[object Object]" in the MCP transport. */
|
|
115
|
-
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
117
|
+
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
118
|
+
ToolResponse<ProposedPolicy> & {
|
|
119
|
+
explain?: {
|
|
120
|
+
predicateTree: PredicateNode | null
|
|
121
|
+
simulation: SimulationResult
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
> {
|
|
116
125
|
const parsed = SynthesizePolicyInputSchema.safeParse(raw)
|
|
117
126
|
if (!parsed.success) {
|
|
118
127
|
return {
|
|
@@ -128,7 +137,11 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
128
137
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
129
138
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
130
139
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
131
|
-
return await synthesizeFromMandate(
|
|
140
|
+
return await synthesizeFromMandate(
|
|
141
|
+
input.mandate as MandateSpec,
|
|
142
|
+
ozConfig,
|
|
143
|
+
input.explain === true ? { explain: true } : {}
|
|
144
|
+
)
|
|
132
145
|
}
|
|
133
146
|
// recording source
|
|
134
147
|
const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
|
|
@@ -141,6 +154,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
141
154
|
? { confidenceOverride: input.confidenceOverride }
|
|
142
155
|
: {}),
|
|
143
156
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
157
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
144
158
|
} as SynthesizeFromRecordingOptions,
|
|
145
159
|
ozConfig
|
|
146
160
|
)
|
package/src/run/schemas.ts
CHANGED
|
@@ -197,6 +197,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
|
|
|
197
197
|
source: z.literal('mandate'),
|
|
198
198
|
mandate: MandateSpecSchema,
|
|
199
199
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
200
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
201
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
202
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
203
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
204
|
+
explain: z.boolean().optional(),
|
|
200
205
|
})
|
|
201
206
|
|
|
202
207
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
@@ -224,6 +229,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
|
224
229
|
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
225
230
|
interpreter: InterpreterOptionsSchema.optional(),
|
|
226
231
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
232
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
233
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
234
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
235
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
236
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
237
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
238
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
239
|
+
// explain.
|
|
240
|
+
explain: z.boolean().optional(),
|
|
227
241
|
})
|
|
228
242
|
|
|
229
243
|
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
@@ -13,14 +13,27 @@ import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
|
13
13
|
import type { ToolResponse } from '../errors.ts'
|
|
14
14
|
import { mandateToPolicyIR } from '../mandate/to-ir.ts'
|
|
15
15
|
import type { MandateSpec } from '../mandate/types.ts'
|
|
16
|
-
import type { ProposedPolicy } from '../types.ts'
|
|
16
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts'
|
|
17
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
17
18
|
|
|
18
19
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
|
|
19
20
|
|
|
20
21
|
export function synthesizeFromMandate(
|
|
21
22
|
spec: MandateSpec,
|
|
22
|
-
ozConfig: OzAdapterConfig
|
|
23
|
-
|
|
23
|
+
ozConfig: OzAdapterConfig,
|
|
24
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
25
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
26
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
27
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
28
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
29
|
+
* altered. */
|
|
30
|
+
opts?: { explain?: true }
|
|
31
|
+
): ToolResponse<ProposedPolicy> & {
|
|
32
|
+
explain?: {
|
|
33
|
+
predicateTree: PredicateNode | null
|
|
34
|
+
simulation: SimulationResult
|
|
35
|
+
}
|
|
36
|
+
} {
|
|
24
37
|
const ir = mandateToPolicyIR(spec)
|
|
25
38
|
const adapter = createOzAdapter(ozConfig)
|
|
26
39
|
const result = adapter.compile(ir)
|
|
@@ -42,5 +55,28 @@ export function synthesizeFromMandate(
|
|
|
42
55
|
...result.proposed,
|
|
43
56
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
44
57
|
}
|
|
45
|
-
|
|
58
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
59
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
60
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
61
|
+
const envelope: ToolResponse<ProposedPolicy> & {
|
|
62
|
+
explain?: {
|
|
63
|
+
predicateTree: PredicateNode | null
|
|
64
|
+
simulation: SimulationResult
|
|
65
|
+
}
|
|
66
|
+
} = { ok: true, data: proposed }
|
|
67
|
+
if (opts?.explain) {
|
|
68
|
+
envelope.explain = {
|
|
69
|
+
predicateTree: null,
|
|
70
|
+
simulation: {
|
|
71
|
+
permit: {
|
|
72
|
+
tx: 'deny',
|
|
73
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
74
|
+
},
|
|
75
|
+
evaluatedCases: [],
|
|
76
|
+
backend: 'ts-model',
|
|
77
|
+
simulatorVersion: 'not-run',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return envelope
|
|
46
82
|
}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type RecordedTransaction,
|
|
53
53
|
SOROBAN_LIMITS,
|
|
54
54
|
} from '../types.ts'
|
|
55
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
55
56
|
import {
|
|
56
57
|
type ComposeOptions,
|
|
57
58
|
type ComposeUserResponses,
|
|
@@ -101,13 +102,36 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
101
102
|
userResponses?: ComposeUserResponses
|
|
102
103
|
confidenceOverride?: { threshold: number }
|
|
103
104
|
interpreter?: InterpreterAdapterOptions
|
|
105
|
+
/** --explain opt-in. When true, the orchestrator attaches the
|
|
106
|
+
* in-memory `PredicateNode` + the corresponding `SimulationResult`
|
|
107
|
+
* to the success envelope so the CLI can render a human-readable
|
|
108
|
+
* review card. Absent or false -> the success envelope is unchanged
|
|
109
|
+
* (byte-identical to today). The flag is ADDITIVE: the existing
|
|
110
|
+
* ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
|
|
111
|
+
* never altered by enabling explain. */
|
|
112
|
+
explain?: true
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
export function synthesizeFromRecording(
|
|
107
116
|
tx: RecordedTransaction,
|
|
108
117
|
opts: SynthesizeFromRecordingOptions,
|
|
109
118
|
ozConfig: OzAdapterConfig
|
|
110
|
-
): ToolResponse<ProposedPolicy> {
|
|
119
|
+
): ToolResponse<ProposedPolicy> & {
|
|
120
|
+
/** Present iff `opts.explain === true` and the synthesis succeeded. The
|
|
121
|
+
* `predicateTree` is the exact in-memory `PredicateNode` (canonical
|
|
122
|
+
* JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
|
|
123
|
+
* - reading the predicate tree from the policy document is therefore
|
|
124
|
+
* unnecessary; the orchestrator carries it through. The `simulation`
|
|
125
|
+
* is the verdict produced by the same self-verify pipeline that already
|
|
126
|
+
* runs in the recording path (runHarness + evaluate), so the value is
|
|
127
|
+
* REAL not synthetic. Absent when the synthesis did not engage the
|
|
128
|
+
* interpreter (no interpreter opts supplied) - in that case the CLI
|
|
129
|
+
* builds a minimal honest SimulationResult downstream. */
|
|
130
|
+
explain?: {
|
|
131
|
+
predicateTree: PredicateNode | null
|
|
132
|
+
simulation: SimulationResult
|
|
133
|
+
}
|
|
134
|
+
} {
|
|
111
135
|
// Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
|
|
112
136
|
// with a string `.code`) inside the body is converted to a structured
|
|
113
137
|
// `{ok:false, error}`; anything else is rethrown so genuine bugs crash
|
|
@@ -159,7 +183,12 @@ function synthesizeFromRecordingInner(
|
|
|
159
183
|
tx: RecordedTransaction,
|
|
160
184
|
opts: SynthesizeFromRecordingOptions,
|
|
161
185
|
ozConfig: OzAdapterConfig
|
|
162
|
-
): ToolResponse<ProposedPolicy> {
|
|
186
|
+
): ToolResponse<ProposedPolicy> & {
|
|
187
|
+
explain?: {
|
|
188
|
+
predicateTree: PredicateNode | null
|
|
189
|
+
simulation: SimulationResult
|
|
190
|
+
}
|
|
191
|
+
} {
|
|
163
192
|
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
164
193
|
const invalid = validateOptions(opts)
|
|
165
194
|
if (invalid) return { ok: false, error: invalid }
|
|
@@ -272,6 +301,15 @@ function synthesizeFromRecordingInner(
|
|
|
272
301
|
}
|
|
273
302
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
|
|
274
303
|
|
|
304
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
305
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
306
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
307
|
+
// already gates the synthesis (it is not a parallel simulation); the
|
|
308
|
+
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
309
|
+
// after the gate, so the explain hook reuses them - no extra work.
|
|
310
|
+
let explain: PredicateNode | null = null
|
|
311
|
+
let explainSim: SimulationResult | null = null
|
|
312
|
+
|
|
275
313
|
// 5. OZ compile (always runs).
|
|
276
314
|
const ozAdapter = createOzAdapter(ozConfig)
|
|
277
315
|
const compileRes = ozAdapter.compile(composed.ir)
|
|
@@ -449,7 +487,6 @@ function synthesizeFromRecordingInner(
|
|
|
449
487
|
},
|
|
450
488
|
}
|
|
451
489
|
}
|
|
452
|
-
|
|
453
490
|
const evalResult = evaluate(finalPredicate, permitCtx)
|
|
454
491
|
if (!evalResult.permit) {
|
|
455
492
|
return {
|
|
@@ -464,6 +501,37 @@ function synthesizeFromRecordingInner(
|
|
|
464
501
|
}
|
|
465
502
|
}
|
|
466
503
|
|
|
504
|
+
// --explain capture: the interpreter path already produced the real
|
|
505
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
506
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
507
|
+
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
508
|
+
// each deny case to surface its concrete reason; the harness only
|
|
509
|
+
// records whether the got-matches-expected boundary held.
|
|
510
|
+
if (opts.explain) {
|
|
511
|
+
explain = finalPredicate
|
|
512
|
+
const evaluatedCases: SimulationResult['evaluatedCases'] = [
|
|
513
|
+
{
|
|
514
|
+
dimension: 'permit',
|
|
515
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
516
|
+
reason: 'matches recorded call',
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
for (const deny of harnessCases.denies) {
|
|
520
|
+
const r = evaluate(finalPredicate, deny.ctx)
|
|
521
|
+
evaluatedCases.push({
|
|
522
|
+
dimension: deny.dimension,
|
|
523
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
524
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
explainSim = {
|
|
528
|
+
permit: { tx: 'permit' },
|
|
529
|
+
evaluatedCases,
|
|
530
|
+
backend: 'ts-model',
|
|
531
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
467
535
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
468
536
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
469
537
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -566,7 +634,38 @@ function synthesizeFromRecordingInner(
|
|
|
566
634
|
],
|
|
567
635
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
568
636
|
}
|
|
569
|
-
|
|
637
|
+
// --explain success envelope. The interpreter path populated
|
|
638
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
639
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
640
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
641
|
+
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
642
|
+
// interpreter was never engaged, so permit is deny with a truthful
|
|
643
|
+
// reason and evaluatedCases is empty.
|
|
644
|
+
const envelope: ToolResponse<ProposedPolicy> & {
|
|
645
|
+
explain?: {
|
|
646
|
+
predicateTree: PredicateNode | null
|
|
647
|
+
simulation: SimulationResult
|
|
648
|
+
}
|
|
649
|
+
} = { ok: true, data: proposed }
|
|
650
|
+
if (opts.explain) {
|
|
651
|
+
if (explainSim) {
|
|
652
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim }
|
|
653
|
+
} else {
|
|
654
|
+
envelope.explain = {
|
|
655
|
+
predicateTree: null,
|
|
656
|
+
simulation: {
|
|
657
|
+
permit: {
|
|
658
|
+
tx: 'deny',
|
|
659
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
660
|
+
},
|
|
661
|
+
evaluatedCases: [],
|
|
662
|
+
backend: 'ts-model',
|
|
663
|
+
simulatorVersion: 'not-run',
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return envelope
|
|
570
669
|
}
|
|
571
670
|
|
|
572
671
|
// Re-export the throwToolError helper for callers that need to surface a
|