@crediolabs/policy-synth 0.1.5 → 0.1.6

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.
Files changed (62) hide show
  1. package/dist/record/decode.js +43 -0
  2. package/dist/record/freshness.d.ts +14 -1
  3. package/dist/record/freshness.js +32 -2
  4. package/dist/record/index.d.ts +11 -0
  5. package/dist/record/index.js +25 -0
  6. package/dist/record/movements.d.ts +15 -3
  7. package/dist/record/movements.js +42 -7
  8. package/dist/run/index.d.ts +13 -1
  9. package/dist/run/index.js +76 -18
  10. package/dist/run/schemas.d.ts +13 -0
  11. package/dist/run/schemas.js +8 -0
  12. package/dist/synth/address.d.ts +7 -0
  13. package/dist/synth/address.js +12 -0
  14. package/dist/synth/compose-from-recording.d.ts +4 -3
  15. package/dist/synth/compose-from-recording.js +16 -6
  16. package/dist/synth/deny-cases.d.ts +12 -2
  17. package/dist/synth/deny-cases.js +58 -2
  18. package/dist/synth/index.d.ts +1 -0
  19. package/dist/synth/index.js +4 -0
  20. package/dist/synth/minimize.d.ts +1 -1
  21. package/dist/synth/minimize.js +3 -3
  22. package/dist/synth/synthesize-from-recording.js +54 -6
  23. package/dist/types.d.ts +24 -1
  24. package/dist-cjs/record/decode.js +43 -0
  25. package/dist-cjs/record/freshness.d.ts +14 -1
  26. package/dist-cjs/record/freshness.js +32 -2
  27. package/dist-cjs/record/index.d.ts +11 -0
  28. package/dist-cjs/record/index.js +25 -0
  29. package/dist-cjs/record/movements.d.ts +15 -3
  30. package/dist-cjs/record/movements.js +42 -7
  31. package/dist-cjs/run/index.d.ts +13 -1
  32. package/dist-cjs/run/index.js +76 -17
  33. package/dist-cjs/run/schemas.d.ts +13 -0
  34. package/dist-cjs/run/schemas.js +8 -0
  35. package/dist-cjs/synth/address.d.ts +7 -0
  36. package/dist-cjs/synth/address.js +15 -0
  37. package/dist-cjs/synth/compose-from-recording.d.ts +4 -3
  38. package/dist-cjs/synth/compose-from-recording.js +16 -6
  39. package/dist-cjs/synth/deny-cases.d.ts +12 -2
  40. package/dist-cjs/synth/deny-cases.js +60 -2
  41. package/dist-cjs/synth/index.d.ts +1 -0
  42. package/dist-cjs/synth/index.js +6 -1
  43. package/dist-cjs/synth/minimize.d.ts +1 -1
  44. package/dist-cjs/synth/minimize.js +3 -3
  45. package/dist-cjs/synth/synthesize-from-recording.js +53 -5
  46. package/dist-cjs/types.d.ts +24 -1
  47. package/package.json +1 -1
  48. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
  49. package/src/record/corpus-fixtures.json +532 -0
  50. package/src/record/decode.ts +42 -0
  51. package/src/record/freshness.ts +34 -2
  52. package/src/record/index.ts +40 -0
  53. package/src/record/movements.ts +40 -7
  54. package/src/run/index.ts +80 -16
  55. package/src/run/schemas.ts +8 -0
  56. package/src/synth/address.ts +14 -0
  57. package/src/synth/compose-from-recording.ts +20 -9
  58. package/src/synth/deny-cases.ts +66 -2
  59. package/src/synth/index.ts +4 -0
  60. package/src/synth/minimize.ts +7 -3
  61. package/src/synth/synthesize-from-recording.ts +59 -6
  62. package/src/types.ts +18 -1
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.ToolErrorSchema = exports.SynthesizePolicyInputSchema = exports.RecordTransactionInputSchema = exports.RecordedTransactionSchema = exports.OzAdapterConfigSchema = exports.NetworkSchema = exports.MandateSpecSchema = exports.InterpreterOptionsSchema = exports.ComposeUserResponsesSchema = void 0;
22
22
  exports.runRecordTransaction = runRecordTransaction;
23
23
  exports.runSynthesizePolicy = runSynthesizePolicy;
24
+ exports.caughtError = caughtError;
24
25
  const index_ts_1 = require("../index.js");
25
26
  const schemas_ts_1 = require("./schemas.js");
26
27
  // Re-export the underlying Zod schemas so the MCP package (and any other
@@ -148,23 +149,19 @@ function validationError(toolName, issues) {
148
149
  };
149
150
  }
150
151
  /** Build a canonical ToolError for a thrown exception caught by the tool
151
- * envelope. We refuse to surface the raw exception's text (the MCP SDK
152
- * stringifies thrown objects as "[object Object]"), so we extract a
153
- * string-friendly message and tag the original error in `details` for the
154
- * agent to inspect. The `code` is the tool's domain code (RECORDING_FAILED
155
- * for `record_transaction`, SYNTHESIS_ERROR for `synthesize_policy`) so the
156
- * agent dispatches on the same code the structured ToolError would carry. */
152
+ * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
153
+ * default, so we extract a string-friendly message and tag the original
154
+ * error in `details` for the agent to inspect. The `code` is the tool's
155
+ * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
156
+ * for `synthesize_policy`) so the agent dispatches on the same code the
157
+ * structured ToolError would carry.
158
+ *
159
+ * Exported as `_caughtError` (the leading underscore signals the test-only
160
+ * seam) so the suite in run/index.test.ts can drive the envelope path
161
+ * without standing up a full recordTransaction pipeline. */
157
162
  function caughtError(toolName, code, e) {
158
- let message;
159
- if (e instanceof Error) {
160
- message = e.message || `${toolName}: unknown error`;
161
- }
162
- else if (typeof e === 'string') {
163
- message = e;
164
- }
165
- else {
166
- message = `${toolName}: caught non-Error throw of type ${typeof e}`;
167
- }
163
+ const message = describeThrown(e, toolName);
164
+ const details = { thrown: safeStringify(e) };
168
165
  return {
169
166
  code,
170
167
  message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
@@ -173,6 +170,68 @@ function caughtError(toolName, code, e) {
173
170
  remediation: {
174
171
  toolCall: { name: toolName, args: {} },
175
172
  },
176
- details: { thrown: String(e) },
173
+ details,
177
174
  };
178
175
  }
176
+ /** Build a human-readable message for an unknown caught value. Order matters:
177
+ * 1. `Error` instances use `.message` (or fallback to the class name).
178
+ * 2. Native strings pass through verbatim.
179
+ * 3. Objects with a string `message` field use that field (mirrors the
180
+ * shape thrown by some SDKs that package errors as plain objects).
181
+ * 4. Anything else falls back to a JSON-shaped summary, never to
182
+ * "[object Object]".
183
+ * The full payload is also captured in `details.thrown` via
184
+ * `safeStringify` so the agent can inspect the original value without
185
+ * risking an infinite loop on circular refs. */
186
+ function describeThrown(e, toolName) {
187
+ if (e instanceof Error) {
188
+ return e.message || `${toolName}: ${e.name || 'Error'}`;
189
+ }
190
+ if (typeof e === 'string') {
191
+ return e;
192
+ }
193
+ if (e !== null && typeof e === 'object') {
194
+ const obj = e;
195
+ const m = obj.message;
196
+ if (typeof m === 'string' && m.length > 0) {
197
+ return truncate(m);
198
+ }
199
+ return truncate(safeStringify(e));
200
+ }
201
+ return `${toolName}: caught non-Error throw of type ${typeof e}`;
202
+ }
203
+ /** Hard cap on the human-readable message embedded in the ToolError. The full
204
+ * payload is still preserved in `details.thrown` so the agent can inspect
205
+ * it - the truncation is only to keep the top-level message small enough to
206
+ * fit comfortably in transport logs. */
207
+ const MAX_MESSAGE_LEN = 512;
208
+ /** JSON.stringify that survives circular refs and very large payloads. The
209
+ * output is itself bounded by the same `MAX_DETAILS_LEN` so a thrown object
210
+ * with megabytes of buffer data cannot bloat the WHOLE envelope. */
211
+ const MAX_DETAILS_LEN = 4096;
212
+ function safeStringify(v) {
213
+ const seen = new WeakSet();
214
+ const json = JSON.stringify(v, (_k, value) => {
215
+ if (typeof value === 'bigint')
216
+ return value.toString();
217
+ if (typeof value === 'function')
218
+ return `[function ${value.name || 'anonymous'}]`;
219
+ if (value instanceof Error) {
220
+ return { name: value.name, message: value.message, stack: value.stack };
221
+ }
222
+ if (value !== null && typeof value === 'object') {
223
+ if (seen.has(value))
224
+ return '[Circular]';
225
+ seen.add(value);
226
+ }
227
+ return value;
228
+ }, 2);
229
+ if (json === undefined)
230
+ return '<unserializable>';
231
+ return truncate(json, MAX_DETAILS_LEN);
232
+ }
233
+ function truncate(s, cap = MAX_MESSAGE_LEN) {
234
+ if (s.length <= cap)
235
+ return s;
236
+ return `${s.slice(0, cap)}\n…[truncated ${s.length - cap} chars]`;
237
+ }
@@ -446,16 +446,19 @@ export declare const ComposeUserResponsesSchema: z.ZodObject<{
446
446
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
447
447
  limitAmount: z.ZodOptional<z.ZodString>;
448
448
  invocationLimit: z.ZodOptional<z.ZodNumber>;
449
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
449
450
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
450
451
  windowSeconds: z.ZodOptional<z.ZodNumber>;
451
452
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
452
453
  limitAmount: z.ZodOptional<z.ZodString>;
453
454
  invocationLimit: z.ZodOptional<z.ZodNumber>;
455
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
454
456
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
455
457
  windowSeconds: z.ZodOptional<z.ZodNumber>;
456
458
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
457
459
  limitAmount: z.ZodOptional<z.ZodString>;
458
460
  invocationLimit: z.ZodOptional<z.ZodNumber>;
461
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
459
462
  }, z.ZodTypeAny, "passthrough">>;
460
463
  /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
461
464
  export declare const OzAdapterConfigSchema: z.ZodObject<{
@@ -1003,16 +1006,19 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1003
1006
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1004
1007
  limitAmount: z.ZodOptional<z.ZodString>;
1005
1008
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1009
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1006
1010
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
1007
1011
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1008
1012
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1009
1013
  limitAmount: z.ZodOptional<z.ZodString>;
1010
1014
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1015
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1011
1016
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
1012
1017
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1013
1018
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1014
1019
  limitAmount: z.ZodOptional<z.ZodString>;
1015
1020
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1021
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1016
1022
  }, z.ZodTypeAny, "passthrough">>>;
1017
1023
  confidenceOverride: z.ZodOptional<z.ZodObject<{
1018
1024
  threshold: z.ZodNumber;
@@ -1130,6 +1136,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1130
1136
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1131
1137
  limitAmount: z.ZodOptional<z.ZodString>;
1132
1138
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1139
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1133
1140
  }, z.ZodTypeAny, "passthrough"> | undefined;
1134
1141
  confidenceOverride?: {
1135
1142
  threshold: number;
@@ -1193,6 +1200,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1193
1200
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1194
1201
  limitAmount: z.ZodOptional<z.ZodString>;
1195
1202
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1203
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1196
1204
  }, z.ZodTypeAny, "passthrough"> | undefined;
1197
1205
  confidenceOverride?: {
1198
1206
  threshold: number;
@@ -1649,16 +1657,19 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1649
1657
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1650
1658
  limitAmount: z.ZodOptional<z.ZodString>;
1651
1659
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1660
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1652
1661
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
1653
1662
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1654
1663
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1655
1664
  limitAmount: z.ZodOptional<z.ZodString>;
1656
1665
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1666
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1657
1667
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
1658
1668
  windowSeconds: z.ZodOptional<z.ZodNumber>;
1659
1669
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1660
1670
  limitAmount: z.ZodOptional<z.ZodString>;
1661
1671
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1672
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1662
1673
  }, z.ZodTypeAny, "passthrough">>>;
1663
1674
  confidenceOverride: z.ZodOptional<z.ZodObject<{
1664
1675
  threshold: z.ZodNumber;
@@ -1776,6 +1787,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1776
1787
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1777
1788
  limitAmount: z.ZodOptional<z.ZodString>;
1778
1789
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1790
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1779
1791
  }, z.ZodTypeAny, "passthrough"> | undefined;
1780
1792
  confidenceOverride?: {
1781
1793
  threshold: number;
@@ -1839,6 +1851,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1839
1851
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
1840
1852
  limitAmount: z.ZodOptional<z.ZodString>;
1841
1853
  invocationLimit: z.ZodOptional<z.ZodNumber>;
1854
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
1842
1855
  }, z.ZodTypeAny, "passthrough"> | undefined;
1843
1856
  confidenceOverride?: {
1844
1857
  threshold: number;
@@ -18,6 +18,7 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.ToolErrorSchema = exports.SynthesizePolicyInputSchema = exports.SynthesizePolicyRecordingInputSchema = exports.InterpreterOptionsSchema = exports.SynthesizePolicyMandateInputSchema = exports.RecordTransactionInputSchema = exports.OzAdapterConfigSchema = exports.ComposeUserResponsesSchema = exports.MandateSpecSchema = exports.RecordedTransactionSchema = exports.ParseConfidenceSchema = exports.OnChainEventSchema = exports.TokenMovementSchema = exports.ContractInvocationSchema = exports.ScValSchema = exports.NetworkSchema = void 0;
20
20
  const zod_1 = require("zod");
21
+ const address_ts_1 = require("../synth/address.js");
21
22
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
22
23
  * installed on-chain, so reject it at the boundary (fail-closed). */
23
24
  const U32_MAX = 4294967295;
@@ -129,6 +130,13 @@ exports.ComposeUserResponsesSchema = zod_1.z
129
130
  .regex(/^[0-9]+$/)
130
131
  .optional(),
131
132
  invocationLimit: zod_1.z.number().int().positive().optional(),
133
+ // Swap recipient allowlist (SoroSwap call_arg[3]). Each entry must be a
134
+ // Stellar address (G... wallet or C... contract); supplying it REPLACES the
135
+ // default pin to the recorded recipient. Validated with the shared StrKey
136
+ // helper (no hand-rolled regex).
137
+ swapRecipientAllowlist: zod_1.z
138
+ .array(zod_1.z.string().refine(address_ts_1.isStellarAddress, 'must be a Stellar address (G... or C...)'))
139
+ .optional(),
132
140
  })
133
141
  .passthrough();
134
142
  /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
@@ -0,0 +1,7 @@
1
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
2
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
3
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
4
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
5
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
6
+ * (`C...`). */
7
+ export declare function isStellarAddress(s: string): boolean;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ // src/synth/address.ts - Stellar address validation shared by the CLI + the
3
+ // run-layer Zod schema.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.isStellarAddress = isStellarAddress;
6
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
7
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
8
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
9
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
10
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
11
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
12
+ * (`C...`). */
13
+ function isStellarAddress(s) {
14
+ return stellar_sdk_1.StrKey.isValidEd25519PublicKey(s) || stellar_sdk_1.StrKey.isValidContract(s);
15
+ }
@@ -28,9 +28,10 @@ export interface ComposeUserResponses {
28
28
  * entries on the same asset emit multiple leaves. */
29
29
  oraclePriceBound?: OraclePriceBound[];
30
30
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
31
- * swap_exact_tokens_for_tokens). Absent -> RECIPIENT_ALLOWLIST_EMPTY
32
- * ambiguity (the caller is prompted; proceeding without an allowlist leaves
33
- * the recipient unconstrained). */
31
+ * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
32
+ * pin. Absent -> the recipient is pinned to the recorded value (mirroring
33
+ * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
34
+ * a silent free pass. */
34
35
  swapRecipientAllowlist?: string[];
35
36
  }
36
37
  /** Composition options. */
@@ -311,11 +311,16 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
311
311
  }
312
312
  // Swap recipient (call_arg[3]): when the caller supplies
313
313
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
314
- // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
315
- // prompted; proceeding without an allowlist leaves the recipient
316
- // unconstrained in the predicate. The ambiguity is only surfaced when
317
- // the interpreter is enabled - if it isn't, the swap recipient is just
318
- // ignored (today's behaviour).
314
+ // arg. When absent, PIN the recipient to the recorded value - mirroring the
315
+ // SEP-41 `to` arg above: the recorded flow went to exactly one recipient, so
316
+ // pinning it is the minimal policy that permits exactly that flow. Leaving
317
+ // it unconstrained would permit an arbitrary recipient (an evil twin with
318
+ // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
319
+ // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
320
+ // widen it), never as a silent free pass. The recipient is only enforceable
321
+ // via the interpreter predicate, so the pin (and the ambiguity) apply only
322
+ // when the interpreter is enabled - otherwise the swap recipient is ignored
323
+ // (today's behaviour, matching the other SoroSwap constraints).
319
324
  const recipientArg = topLevel.args[3];
320
325
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
321
326
  const cond = {
@@ -331,9 +336,14 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
331
336
  }
332
337
  }
333
338
  else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
339
+ interpreterConstraints.push({
340
+ op: 'in',
341
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
342
+ values: [recipientArg.value],
343
+ });
334
344
  ambiguities.push({
335
345
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
336
- question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
346
+ question: `No recipient allowlist supplied; the swap recipient (call_arg[3]) was pinned to the recorded value ${recipientArg.value}. To permit additional recipients, supply an allowlist (CLI: --recipient <C...|G...>, repeatable; MCP: userResponses.swapRecipientAllowlist) - it REPLACES this default pin.`,
337
347
  });
338
348
  }
339
349
  }
@@ -8,5 +8,15 @@ export interface GeneratedCases {
8
8
  permit: EvalContext;
9
9
  denies: DenyCase[];
10
10
  }
11
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
12
- export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases;
11
+ declare const OVERPERMISSIVE_DIMENSIONS: readonly ["argument_reorder"];
12
+ declare const ORIGINAL_DIMENSIONS: string[];
13
+ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
14
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
15
+ *
16
+ * @param predicate - the synthesized predicate
17
+ * @param permitCtx - EvalContext for the intended (permitted) call
18
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
19
+ * all known dimensions (including over-permissiveness mutations)
20
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
21
+ * existing fixtures do not regress. */
22
+ export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OVERPERMISSIVE_DIMENSIONS = exports.ORIGINAL_DIMENSIONS = void 0;
3
4
  exports.generateCases = generateCases;
4
5
  const types_ts_1 = require("../types.js");
5
6
  const predicate_literals_ts_1 = require("./predicate-literals.js");
@@ -14,8 +15,39 @@ const ADJACENT_ASSETS = [
14
15
  'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
15
16
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
16
17
  ];
17
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
18
- function generateCases(predicate, permitCtx) {
18
+ // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
19
+ // self-verify call so existing fixtures still emit policies. The harness tests
20
+ // them as FINDINGS against the already-emitted policy.
21
+ const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
22
+ exports.OVERPERMISSIVE_DIMENSIONS = OVERPERMISSIVE_DIMENSIONS;
23
+ // The 15 dimensions the synth pipeline uses for self-verify and minimise.
24
+ const ORIGINAL_DIMENSIONS = [
25
+ 'amount',
26
+ 'asset',
27
+ 'contract',
28
+ 'function',
29
+ 'timing',
30
+ 'time_window',
31
+ 'invocation_count',
32
+ 'arg_amount_bound',
33
+ 'arg_bound',
34
+ 'scope_contract_fn_arg',
35
+ 'oracle_stale',
36
+ 'oracle_missing',
37
+ 'oracle_deviation_exceeded',
38
+ 'oracle_paused',
39
+ 'soroswap_allowed_path',
40
+ ];
41
+ exports.ORIGINAL_DIMENSIONS = ORIGINAL_DIMENSIONS;
42
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
43
+ *
44
+ * @param predicate - the synthesized predicate
45
+ * @param permitCtx - EvalContext for the intended (permitted) call
46
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
47
+ * all known dimensions (including over-permissiveness mutations)
48
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
49
+ * existing fixtures do not regress. */
50
+ function generateCases(predicate, permitCtx, dimensions) {
19
51
  const facts = inspectPredicate(predicate);
20
52
  const denies = [];
21
53
  for (const comparison of facts.comparisons) {
@@ -147,6 +179,32 @@ function generateCases(predicate, permitCtx) {
147
179
  ctx.args[comparison.left.index] = differentVector(ctx.args[comparison.left.index]);
148
180
  denies.push({ dimension: 'soroswap_allowed_path', ctx });
149
181
  }
182
+ // --- argument_reorder: swap first two address args ---
183
+ // Skipped when dimensions filter is active so the synth pipeline can emit a policy;
184
+ // the over-permissiveness harness then tests this mutation as a FINDING.
185
+ const constrainedArgIndices = new Set();
186
+ for (const comparison of facts.comparisons) {
187
+ if (comparison.left.kind === 'call_arg')
188
+ constrainedArgIndices.add(comparison.left.index);
189
+ }
190
+ for (const membership of facts.memberships) {
191
+ if (membership.needle.kind === 'call_arg')
192
+ constrainedArgIndices.add(membership.needle.index);
193
+ }
194
+ const hasConstrainedArg = constrainedArgIndices.size > 0;
195
+ if ((!dimensions || dimensions.includes('argument_reorder')) &&
196
+ hasConstrainedArg &&
197
+ permitCtx.args.length >= 2 &&
198
+ permitCtx.args[0]?.type === 'address' &&
199
+ permitCtx.args[1]?.type === 'address') {
200
+ const ctx = cloneContext(permitCtx);
201
+ // Guard above guarantees args[0] and args[1] exist and are address-typed.
202
+ const a0 = ctx.args[0];
203
+ const a1 = ctx.args[1];
204
+ ctx.args[0] = a1;
205
+ ctx.args[1] = a0;
206
+ denies.push({ dimension: 'argument_reorder', ctx });
207
+ }
150
208
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
151
209
  return { permit: cloneContext(permitCtx), denies };
152
210
  }
@@ -1,3 +1,4 @@
1
+ export { isStellarAddress } from './address.ts';
1
2
  export { type ComposeOptions, type ComposeResult, composeFromRecording, } from './compose-from-recording.ts';
2
3
  export { type DenyCase, type GeneratedCases, generateCases, } from './deny-cases.ts';
3
4
  export { type EvalContext, type EvalResult, evaluate } from './evaluate.ts';
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  // src/synth/index.ts - re-export the synthesizer front-ends.
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.synthesizeFromRecording = exports.synthesizeFromMandate = exports.scopeToContextRuleType = exports.decideScope = exports.minimize = exports.lower = exports.runHarness = exports.evaluate = exports.generateCases = exports.composeFromRecording = void 0;
4
+ exports.synthesizeFromRecording = exports.synthesizeFromMandate = exports.scopeToContextRuleType = exports.decideScope = exports.minimize = exports.lower = exports.runHarness = exports.evaluate = exports.generateCases = exports.composeFromRecording = exports.isStellarAddress = void 0;
5
+ // Exported because `swapRecipientAllowlist` is part of the public synthesis
6
+ // input: a caller assembling one needs the same validator the schema applies,
7
+ // and re-deriving it elsewhere means a second address check that can drift.
8
+ var address_ts_1 = require("./address.js");
9
+ Object.defineProperty(exports, "isStellarAddress", { enumerable: true, get: function () { return address_ts_1.isStellarAddress; } });
5
10
  var compose_from_recording_ts_1 = require("./compose-from-recording.js");
6
11
  Object.defineProperty(exports, "composeFromRecording", { enumerable: true, get: function () { return compose_from_recording_ts_1.composeFromRecording; } });
7
12
  var deny_cases_ts_1 = require("./deny-cases.js");
@@ -1,4 +1,4 @@
1
1
  import type { PredicateNode } from '../types.ts';
2
2
  import type { EvalContext } from './evaluate.ts';
3
3
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
4
- export declare function minimize(predicate: PredicateNode, permitCtx: EvalContext): PredicateNode;
4
+ export declare function minimize(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): PredicateNode;
@@ -4,17 +4,17 @@ exports.minimize = minimize;
4
4
  const deny_cases_ts_1 = require("./deny-cases.js");
5
5
  const harness_ts_1 = require("./harness.js");
6
6
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
7
- function minimize(predicate, permitCtx) {
7
+ function minimize(predicate, permitCtx, dimensions) {
8
8
  if (predicate.op !== 'and')
9
9
  return predicate;
10
10
  let children = [...predicate.children];
11
11
  let index = 0;
12
12
  while (index < children.length) {
13
13
  const current = { op: 'and', children };
14
- const currentCases = (0, deny_cases_ts_1.generateCases)(current, permitCtx);
14
+ const currentCases = (0, deny_cases_ts_1.generateCases)(current, permitCtx, dimensions);
15
15
  const candidateChildren = children.filter((_, childIndex) => childIndex !== index);
16
16
  const candidate = { op: 'and', children: candidateChildren };
17
- const candidateCases = (0, deny_cases_ts_1.generateCases)(candidate, permitCtx);
17
+ const candidateCases = (0, deny_cases_ts_1.generateCases)(candidate, permitCtx, dimensions);
18
18
  const verificationCases = {
19
19
  permit: candidateCases.permit,
20
20
  denies: mergeDenyCases(currentCases.denies, candidateCases.denies),
@@ -130,6 +130,31 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
130
130
  },
131
131
  };
132
132
  }
133
+ // 1a. Zero-invocation refusal (item 1). A recording with zero contract
134
+ // invocations cleared the parseConfidence gate legitimately (the
135
+ // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
136
+ // policy must scope to an authorized contract call. Refuse before any
137
+ // lower/scope work so the failure is specific and actionable. The
138
+ // recorder's silence is also made visible via `parseConfidence.noInvocations`
139
+ // so consumers can pattern-match without inferring from `invocations: []`
140
+ // next to `overall: 1.0`. The message does NOT ask for an ABI - the
141
+ // failure mode is the recording shape, not decoding coverage.
142
+ const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
143
+ if (hasNoInvocations) {
144
+ return {
145
+ ok: false,
146
+ error: {
147
+ code: 'SYNTHESIS_ERROR',
148
+ message: 'Recording contains no contract invocation to scope a policy to. Re-record a transaction that invokes a Soroban contract function (e.g. an SAC/SEP-41 transfer, a Blend yield-claim, or a SoroSwap swap).',
149
+ severity: 'error',
150
+ retryable: false,
151
+ details: {
152
+ invocations: tx.invocations.length,
153
+ noInvocationsMarker: tx.parseConfidence.noInvocations === true,
154
+ },
155
+ },
156
+ };
157
+ }
133
158
  // Bound the recording size fail-closed (defense-in-depth for direct callers;
134
159
  // the MCP schema caps this too).
135
160
  if (tx.invocations.length > types_ts_1.SOROBAN_LIMITS.maxInvocations) {
@@ -321,8 +346,10 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
321
346
  };
322
347
  }
323
348
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
324
- const finalPredicate = startingPredicate.op === 'and' ? (0, minimize_ts_1.minimize)(startingPredicate, permitCtx) : startingPredicate;
325
- const harnessCases = (0, deny_cases_ts_1.generateCases)(finalPredicate, permitCtx);
349
+ const finalPredicate = startingPredicate.op === 'and'
350
+ ? (0, minimize_ts_1.minimize)(startingPredicate, permitCtx, deny_cases_ts_1.ORIGINAL_DIMENSIONS)
351
+ : startingPredicate;
352
+ const harnessCases = (0, deny_cases_ts_1.generateCases)(finalPredicate, permitCtx, deny_cases_ts_1.ORIGINAL_DIMENSIONS);
326
353
  const harnessResult = (0, harness_ts_1.runHarness)(finalPredicate, harnessCases);
327
354
  if (!harnessResult.ok) {
328
355
  return {
@@ -423,12 +450,24 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
423
450
  ...compileRes.proposed.contextRule,
424
451
  policies: mergedRefs,
425
452
  };
453
+ // When nothing installable was synthesised (no interpreter doc AND no OZ
454
+ // policy refs), an empty `policies` array reads as "no restrictions" rather
455
+ // than "I synthesised nothing". Surface that explicitly so the empty result
456
+ // is never mistaken for a permissive policy - the context rule still exists,
457
+ // but it constrains nothing. (Kept as `{ok:true}` so the documented
458
+ // Path-A/Path-B demo behaviour is preserved - see F3.)
459
+ const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
460
+ ? [
461
+ 'No policy constraints were synthesised: the call to this contract is UNCONSTRAINED by this policy. Enable the interpreter (supply a smart account) to enforce the surfaced constraints.',
462
+ ]
463
+ : [];
426
464
  const proposed = {
427
465
  contextRule: mergedContextRule,
428
466
  policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
429
467
  policyRefs: mergedRefs,
430
468
  parseConfidence: { ...tx.parseConfidence },
431
469
  warnings: [
470
+ ...zeroPolicyWarning,
432
471
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
433
472
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
434
473
  ],
@@ -474,7 +513,7 @@ function validateOptions(opts) {
474
513
  return synthesisError(`invocationLimit must be a positive integer, got: ${ur.invocationLimit}`);
475
514
  }
476
515
  if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
477
- return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
516
+ return synthesisError(`limitAmount must be a positive i128 decimal string within [1, ${I128_MAX}] (2^127-1), got: ${ur.limitAmount}`);
478
517
  }
479
518
  }
480
519
  if (opts.interpreter) {
@@ -524,15 +563,24 @@ function validateOptions(opts) {
524
563
  * contract address derivable from the on-chain account, never a
525
564
  * fixture/LLM-seam marker. */
526
565
  const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
566
+ /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
567
+ * cannot be represented on-chain, so reject it at the synthesis boundary
568
+ * (fail-closed) instead of passing it through as an over-broad spending_limit.
569
+ * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
570
+ const I128_MAX = 2n ** 127n - 1n;
527
571
  function isPositiveInt(n) {
528
572
  return Number.isInteger(n) && n > 0;
529
573
  }
530
- /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
574
+ /** True when `s` is a canonical positive decimal integer inside the signed-i128
575
+ * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
576
+ * it cannot be installed on-chain, and accepting it would emit a spending_limit
577
+ * with an effectively unbounded cap. */
531
578
  function isPositiveI128(s) {
532
579
  if (!/^[0-9]+$/.test(s))
533
580
  return false;
534
581
  try {
535
- return BigInt(s) > 0n;
582
+ const v = BigInt(s);
583
+ return v > 0n && v <= I128_MAX;
536
584
  }
537
585
  catch {
538
586
  return false;
@@ -21,6 +21,17 @@ export type ScVal = {
21
21
  } | {
22
22
  type: 'bytes';
23
23
  value: string;
24
+ }
25
+ /** Map<Symbol, ScVal> carried forward for SAC/SEP-41 transfer event data,
26
+ * which uses a Map shape (key "amount" -> I128) on Stellar mainnet. The
27
+ * entry key is captured as its symbol/form string so readAmount can route
28
+ * to the conventional field name without re-decoding the full XDR. */
29
+ | {
30
+ type: 'map';
31
+ value: Array<{
32
+ key: string;
33
+ val: ScVal;
34
+ }>;
24
35
  } | {
25
36
  type: 'other';
26
37
  value: string;
@@ -272,7 +283,15 @@ export interface AmbiguityPrompt {
272
283
  export type AmbiguityCode = 'DURATION_UNSPECIFIED' | 'AMOUNT_BOUND_MISSING' | 'RECIPIENT_ALLOWLIST_EMPTY' | 'FREQUENCY_BOUND_MISSING' | 'ORACLE_ASSET_UNKNOWN' | 'MULTIPLE_UNRELATED_TARGETS';
273
284
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
274
285
  * breakdown is the diagnostic the user / agent sees when the gate fires. The
275
- * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
286
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`.
287
+ *
288
+ * `noInvocations` (item 1) is a recorder-side marker set ONLY when the envelope
289
+ * contained zero `invokeContract` host functions (e.g. a `createContract` or
290
+ * `uploadContractWasm` op). The math is unchanged: that case still scores 1.0
291
+ * via the `denom === 0` guard. The marker exists so a consumer does not have
292
+ * to infer the situation from `invocations.length === 0` next to `overall: 1.0`
293
+ * — a downstream synth refuses such recordings with a specific, actionable
294
+ * error rather than silently producing a useless empty scope. */
276
295
  export interface ParseConfidence {
277
296
  overall: number;
278
297
  knownContracts: string[];
@@ -285,4 +304,8 @@ export interface ParseConfidence {
285
304
  type: string;
286
305
  }>;
287
306
  thresholdUsed: number;
307
+ /** True iff the recorder decoded zero contract invocations. Absent on
308
+ * recordings that pre-date this field; consumers should treat `undefined`
309
+ * as "no marker" and rely on `invocations.length` as the fallback. */
310
+ noInvocations?: true;
288
311
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",