@crediolabs/policy-synth 1.0.0 → 1.1.1

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 (35) hide show
  1. package/dist/install/build-add-context-rule.js +48 -16
  2. package/dist/install/build-install-policy.d.ts +41 -0
  3. package/dist/install/build-install-policy.js +51 -5
  4. package/dist/predicate/encode.d.ts +12 -0
  5. package/dist/predicate/encode.js +5 -1
  6. package/dist/run/index.d.ts +14 -5
  7. package/dist/run/index.js +263 -14
  8. package/dist/run/schemas.d.ts +2279 -476
  9. package/dist/run/schemas.js +192 -26
  10. package/dist/synth/lower.d.ts +6 -2
  11. package/dist/synth/lower.js +21 -8
  12. package/dist/synth/synthesize-from-recording.js +1 -1
  13. package/dist/types.d.ts +18 -1
  14. package/dist-cjs/install/build-add-context-rule.js +48 -16
  15. package/dist-cjs/install/build-install-policy.d.ts +41 -0
  16. package/dist-cjs/install/build-install-policy.js +52 -5
  17. package/dist-cjs/predicate/encode.d.ts +12 -0
  18. package/dist-cjs/predicate/encode.js +5 -0
  19. package/dist-cjs/run/index.d.ts +14 -5
  20. package/dist-cjs/run/index.js +263 -13
  21. package/dist-cjs/run/schemas.d.ts +2279 -476
  22. package/dist-cjs/run/schemas.js +193 -27
  23. package/dist-cjs/synth/lower.d.ts +6 -2
  24. package/dist-cjs/synth/lower.js +21 -8
  25. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  26. package/dist-cjs/types.d.ts +18 -1
  27. package/package.json +1 -1
  28. package/src/install/build-add-context-rule.ts +65 -21
  29. package/src/install/build-install-policy.ts +100 -12
  30. package/src/predicate/encode.ts +5 -1
  31. package/src/run/index.ts +280 -23
  32. package/src/run/schemas.ts +229 -43
  33. package/src/synth/lower.ts +22 -8
  34. package/src/synth/synthesize-from-recording.ts +1 -1
  35. package/src/types.ts +25 -6
package/dist/run/index.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
  import { createHash } from 'node:crypto';
20
20
  import { rpc } from '@stellar/stellar-sdk';
21
+ import { PLACEHOLDER_INTERPRETER_ADDRESS } from "../adapters/interpreter/adapter.js";
21
22
  import { declarePredicate, encodePredicate, recordTransaction, synthesizeFromRecording, } from "../index.js";
22
23
  import { findAuthorityOverlaps, } from "../install/authority-overlap.js";
23
24
  import { buildInstallPolicyXdr, buildRevokePolicyXdr, rpcClientFromServer, } from "../install/build-install-policy.js";
@@ -25,7 +26,7 @@ import { getInterpreterInfo } from "../install/get-interpreter-info.js";
25
26
  import { accountRuleReaderFromServer, collectObservedRules } from "../install/read-account-rules.js";
26
27
  import { decodePredicate } from "../predicate/decode.js";
27
28
  import { evaluate, generateCases } from "../simulate/index.js";
28
- import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
29
+ import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
29
30
  // Re-export the underlying Zod schemas so the MCP package (and any other
30
31
  // downstream consumer) can import the canonical input shapes from the same
31
32
  // module that owns the tool-body glue. The strict schemas are the source of
@@ -95,7 +96,24 @@ export async function runSynthesizePolicy(raw) {
95
96
  }
96
97
  const input = parsed.data;
97
98
  try {
98
- const recorded = input.recordedTx;
99
+ // `hash` is the agent-friendly alternative to `recordedTx`: re-record here
100
+ // rather than make the caller retype a recording it cannot copy faithfully.
101
+ // A recording failure is returned as-is, so the caller sees why the hash was
102
+ // refused instead of a synthesis error about a payload it never sent.
103
+ let recorded;
104
+ if (input.recordedTx === undefined) {
105
+ const rerecorded = await runRecordTransaction({
106
+ hash: input.transactionHash,
107
+ network: input.network,
108
+ });
109
+ if (!rerecorded.ok) {
110
+ return { ok: false, error: rerecorded.error };
111
+ }
112
+ recorded = rerecorded.data;
113
+ }
114
+ else {
115
+ recorded = input.recordedTx;
116
+ }
99
117
  return await synthesizeFromRecording(recorded, {
100
118
  network: input.network,
101
119
  ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
@@ -117,10 +135,143 @@ export async function runInstallPolicy(raw) {
117
135
  }
118
136
  const input = parsed.data;
119
137
  const network = input.network ?? 'testnet';
138
+ // `fromHash` builds the rule here rather than accepting a transcribed copy.
139
+ // The pinning gates below then run against the rule we just synthesized, so
140
+ // this path is gated identically to a caller-supplied one - it is a shortcut
141
+ // for the caller, never for the checks.
142
+ let rule = input.rule;
143
+ if (rule === undefined && input.fromPredicate !== undefined) {
144
+ const fp = input.fromPredicate;
145
+ let scope;
146
+ try {
147
+ scope = contextTypeForPredicate(decodePredicate(fp.encodedPredicate));
148
+ }
149
+ catch (e) {
150
+ return toolFailure('install_policy', e);
151
+ }
152
+ rule = {
153
+ contextRuleType: scope,
154
+ name: fp.name ?? 'policy',
155
+ validUntilLedger: fp.validUntilLedger ?? null,
156
+ signers: fp.signers.map((address) => ({ kind: 'delegated', address })),
157
+ policies: [
158
+ {
159
+ kind: 'interpreter',
160
+ interpreterAddress: PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
161
+ predicateBlobBase64: fp.encodedPredicate,
162
+ },
163
+ ],
164
+ };
165
+ }
166
+ if (rule === undefined) {
167
+ // Typed rather than inline: every tool body takes `unknown`, so a
168
+ // misspelled key here would compile and fail only at runtime, as a
169
+ // validation error blamed on the caller. Naming the type restores the
170
+ // check on this hop.
171
+ const synthArgs = {
172
+ source: 'recording',
173
+ network,
174
+ transactionHash: input.fromHash?.transactionHash,
175
+ interpreter: { smartAccountAddress: input.smartAccount },
176
+ ...(input.fromHash?.userResponses !== undefined
177
+ ? { userResponses: input.fromHash.userResponses }
178
+ : {}),
179
+ };
180
+ const synthesized = await runSynthesizePolicy(synthArgs);
181
+ if (!synthesized.ok) {
182
+ return { ok: false, error: synthesized.error };
183
+ }
184
+ // The synthesizer saw a spend it could not bound. Installing anyway yields
185
+ // a rule that reads as a cap and enforces nothing, and nothing downstream
186
+ // catches it: it installs cleanly and verifies cleanly, because a missing
187
+ // constraint generates no deny case that could fail. That combination
188
+ // reached the chain once. Refuse rather than emit a warning to skim past.
189
+ const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING');
190
+ if (unbounded && input.allowUnboundedAmount !== true) {
191
+ return {
192
+ ok: false,
193
+ error: {
194
+ code: 'INSTALL_BUILD_FAILED',
195
+ message: 'install_policy: the recorded call spends an amount this policy does not bound, so the rule would constrain everything about the call except how much it moves; set `fromHash.userResponses.limitAmount` to the per-call cap, or `allowUnboundedAmount: true` to install an unbounded rule deliberately',
196
+ severity: 'error',
197
+ retryable: false,
198
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
199
+ },
200
+ };
201
+ }
202
+ // Synthesis leaves the signer set empty - it reads a transaction, and which
203
+ // keys a rule binds is a security decision no single recording answers.
204
+ // The caller names them here.
205
+ rule = {
206
+ ...synthesized.data.contextRule,
207
+ signers: (input.fromHash?.signers ?? []).map((address) => ({
208
+ kind: 'delegated',
209
+ address,
210
+ })),
211
+ };
212
+ }
213
+ // A rule that governs no key is refused on chain, and the refusal arrives as
214
+ // a bare contract error code with nothing to act on. Say what is missing
215
+ // instead, while the caller still has the recording in hand.
216
+ if (rule.signers.length === 0) {
217
+ return {
218
+ ok: false,
219
+ error: {
220
+ code: 'INSTALL_BUILD_FAILED',
221
+ message: 'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
222
+ severity: 'error',
223
+ retryable: false,
224
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
225
+ },
226
+ };
227
+ }
120
228
  // ---- Pinning gates (default-deny) ----
121
229
  const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
122
230
  const expectedRpc = RPC_URL_BY_NETWORK[network];
123
- const pinningError = enforceInterpreterPin(input.rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
231
+ // Synthesis stamps every interpreter policy with the placeholder marker: it
232
+ // is handed a recording, not a network, so it emits a marker rather than
233
+ // inventing a deploy address. Install DOES know the network, and resolves
234
+ // the pin just above, so it fills the marker in here - otherwise the
235
+ // synthesize -> install path is unreachable, because the marker is not a
236
+ // strkey and fails the pin on every call. Only the exact marker is replaced;
237
+ // a caller-supplied address is still checked against the pin unchanged, so
238
+ // this widens nothing.
239
+ rule = {
240
+ ...rule,
241
+ policies: rule.policies.map((p) => p.kind === 'interpreter' && p.interpreterAddress === PLACEHOLDER_INTERPRETER_ADDRESS
242
+ ? { ...p, interpreterAddress: expectedInterpreter }
243
+ : p),
244
+ };
245
+ // A rolling total, when asked for. The predicate bounds each call; this
246
+ // bounds the sum across calls, which is state the interpreter does not keep.
247
+ // Both sit on the one rule and compose as all-of.
248
+ if (input.spendingLimit !== undefined) {
249
+ if (rule.contextRuleType.kind !== 'call_contract') {
250
+ return {
251
+ ok: false,
252
+ error: {
253
+ code: 'INSTALL_BUILD_FAILED',
254
+ message: `install_policy: a spending limit meters transfers of one token, so the rule must be scoped to that token's contract; this rule's scope is "${rule.contextRuleType.kind}"`,
255
+ severity: 'error',
256
+ retryable: false,
257
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
258
+ },
259
+ };
260
+ }
261
+ rule = {
262
+ ...rule,
263
+ policies: [
264
+ ...rule.policies,
265
+ {
266
+ kind: 'spending_limit',
267
+ policyAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
268
+ periodLedgers: input.spendingLimit.periodLedgers,
269
+ spendingLimit: input.spendingLimit.amount,
270
+ },
271
+ ],
272
+ };
273
+ }
274
+ const pinningError = enforceInterpreterPin(rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
124
275
  if (pinningError) {
125
276
  return { ok: false, error: pinningError };
126
277
  }
@@ -136,7 +287,7 @@ export async function runInstallPolicy(raw) {
136
287
  return toolFailure('install_policy', e);
137
288
  }
138
289
  try {
139
- const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter');
290
+ const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter');
140
291
  const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? '';
141
292
  const predicateHash = createHash('sha256')
142
293
  .update(Buffer.from(encodedPredicate, 'base64'))
@@ -145,8 +296,10 @@ export async function runInstallPolicy(raw) {
145
296
  smartAccount: input.smartAccount,
146
297
  sourceAccount: input.sourceAccount,
147
298
  networkPassphrase: NETWORK_PASSPHRASES[network],
148
- rule: input.rule,
149
- installNonce: input.installNonce,
299
+ rule,
300
+ // A fresh rule has no stored nonce, so 1 is the value the interpreter
301
+ // expects unless the caller is deliberately re-installing.
302
+ installNonce: input.installNonce ?? 1,
150
303
  encodedPredicate,
151
304
  predicateHash,
152
305
  rpc: rpcClient,
@@ -171,8 +324,8 @@ export async function runInstallPolicy(raw) {
171
324
  // no existing rule this install replaces. A sentinel no real id
172
325
  // can equal keeps every observed rule in scope.
173
326
  ruleId: -1,
174
- contextType: input.rule.contextRuleType,
175
- signers: input.rule.signers,
327
+ contextType: rule.contextRuleType,
328
+ signers: rule.signers,
176
329
  predicate: decodePredicate(encodedPredicate),
177
330
  },
178
331
  existing: observed,
@@ -266,23 +419,116 @@ function noInvocationError(toolName) {
266
419
  retryable: false,
267
420
  };
268
421
  }
422
+ /** Scope a rule to whatever contract its predicate pins.
423
+ *
424
+ * Taking this from the predicate rather than from a separate argument means
425
+ * the rule's scope cannot drift from what the predicate actually checks. A
426
+ * predicate that pins no contract yields the default (account-wide) type,
427
+ * which is what an unpinned predicate means. Only the top level is walked:
428
+ * a contract pin nested under an `or` does not scope the rule, because the
429
+ * other branch would not be covered by it. */
430
+ export function contextTypeForPredicate(predicate) {
431
+ const conjuncts = predicate.op === 'and' ? predicate.children : [predicate];
432
+ for (const node of conjuncts) {
433
+ if (node.op !== 'eq')
434
+ continue;
435
+ if (node.left?.kind !== 'call_contract')
436
+ continue;
437
+ if (node.right?.kind !== 'literal_address')
438
+ continue;
439
+ return { kind: 'call_contract', contract: node.right.value };
440
+ }
441
+ return { kind: 'default' };
442
+ }
443
+ /** Resolve what `simulate_policy` and `verify_policy` evaluate.
444
+ *
445
+ * Both want a predicate TREE plus the recording it came from, and neither is
446
+ * something a caller holds by default: the tree is only returned by
447
+ * `synthesize_policy` under `explain`, so a caller who did not ask for it has
448
+ * nothing to pass and skips the check. Skipping is the worst outcome here -
449
+ * these two ARE the check - so a transaction hash is accepted instead and the
450
+ * server rebuilds both from it. Recording is deterministic for a settled
451
+ * transaction, so this evaluates the same predicate the synthesiser produced. */
452
+ async function resolveCheckInputs(input, tool) {
453
+ const network = input.network ?? 'testnet';
454
+ // The call to check against: whichever the caller supplied, recording only
455
+ // when they gave a hash instead.
456
+ let permitTx;
457
+ if (input.permitTx !== undefined) {
458
+ permitTx = input.permitTx;
459
+ }
460
+ else {
461
+ const recordArgs = { hash: input.transactionHash, network };
462
+ const recorded = await runRecordTransaction(recordArgs);
463
+ if (!recorded.ok)
464
+ return { ok: false, error: recorded.error };
465
+ permitTx = recorded.data;
466
+ }
467
+ // The thing to check. A caller-supplied predicate wins over re-synthesis,
468
+ // in either form: a DECLARED policy has no recording behind it, so
469
+ // re-deriving one from the transaction would check a different predicate
470
+ // than the one the caller is asking about.
471
+ if (input.predicate !== undefined) {
472
+ return { ok: true, data: { predicate: input.predicate, permitTx } };
473
+ }
474
+ if (input.encodedPredicate !== undefined) {
475
+ try {
476
+ return { ok: true, data: { predicate: decodePredicate(input.encodedPredicate), permitTx } };
477
+ }
478
+ catch (e) {
479
+ return toolFailure(tool, e);
480
+ }
481
+ }
482
+ const synthArgs = {
483
+ source: 'recording',
484
+ network,
485
+ // The schema's inferred type is `passthrough`, so it carries an index
486
+ // signature the core type does not; the shapes agree field for field.
487
+ recordedTx: permitTx,
488
+ explain: true,
489
+ ...(input.smartAccount !== undefined
490
+ ? { interpreter: { smartAccountAddress: input.smartAccount } }
491
+ : {}),
492
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
493
+ };
494
+ const synthesized = await runSynthesizePolicy(synthArgs);
495
+ if (!synthesized.ok)
496
+ return { ok: false, error: synthesized.error };
497
+ const tree = synthesized.explain?.predicateTree;
498
+ if (!tree) {
499
+ return {
500
+ ok: false,
501
+ error: {
502
+ code: TOOL_ERROR_CODE[tool],
503
+ message: `${tool}: synthesis produced no predicate to check for that transaction`,
504
+ severity: 'error',
505
+ retryable: false,
506
+ remediation: { toolCall: { name: tool, args: {} } },
507
+ },
508
+ };
509
+ }
510
+ return { ok: true, data: { predicate: tree, permitTx } };
511
+ }
269
512
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
270
513
  *
271
514
  * The evaluator is a second implementation of the on-chain semantics, and the
272
515
  * conformance harness asserts it agrees with the Rust interpreter case for
273
516
  * case. A verdict here is therefore a claim about what the contract would do,
274
517
  * not a guess. */
275
- export function runSimulatePolicy(raw) {
518
+ export async function runSimulatePolicy(raw) {
276
519
  const parsed = SimulatePolicyInputSchema.safeParse(raw);
277
520
  if (!parsed.success) {
278
521
  return { ok: false, error: validationError('simulate_policy', parsed.error.issues) };
279
522
  }
280
523
  const input = parsed.data;
281
- const ctx = evalContextFromRecording(input.permitTx);
524
+ const resolved = await resolveCheckInputs(input, 'simulate_policy');
525
+ if (!resolved.ok)
526
+ return { ok: false, error: resolved.error };
527
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
282
528
  if (!ctx)
283
529
  return { ok: false, error: noInvocationError('simulate_policy') };
284
530
  try {
285
- const res = evaluate(input.predicate, ctx);
531
+ const res = evaluate(resolved.data.predicate, ctx);
286
532
  return {
287
533
  ok: true,
288
534
  data: {
@@ -356,17 +602,20 @@ export function runDeclarePolicy(raw) {
356
602
  * very transaction it was synthesised from. A deny case that permits means it
357
603
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
358
604
  * true only when neither holds. */
359
- export function runVerifyPolicy(raw) {
605
+ export async function runVerifyPolicy(raw) {
360
606
  const parsed = VerifyPolicyInputSchema.safeParse(raw);
361
607
  if (!parsed.success) {
362
608
  return { ok: false, error: validationError('verify_policy', parsed.error.issues) };
363
609
  }
364
610
  const input = parsed.data;
365
- const ctx = evalContextFromRecording(input.permitTx);
611
+ const resolved = await resolveCheckInputs(input, 'verify_policy');
612
+ if (!resolved.ok)
613
+ return { ok: false, error: resolved.error };
614
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
366
615
  if (!ctx)
367
616
  return { ok: false, error: noInvocationError('verify_policy') };
368
617
  try {
369
- const predicate = input.predicate;
618
+ const predicate = resolved.data.predicate;
370
619
  const cases = generateCases(predicate, ctx);
371
620
  const permitRes = evaluate(predicate, cases.permit);
372
621
  const denies = cases.denies.map((d) => {