@crediolabs/policy-synth 0.1.12 → 0.1.14

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.
@@ -0,0 +1,68 @@
1
+ // src/install/get-interpreter-info.ts - read-only fingerprint lookup for
2
+ // the interpreter contract.
3
+ //
4
+ // Returns the pinned deployment fingerprint + (optionally) compares a
5
+ // caller-supplied live `grammar_version` against the pin. No fabricated
6
+ // audit field; a real deployed-contract check is worth more than a fake
7
+ // reference.
8
+ //
9
+ // Per design decision 5: phase-04's "audit #44" is aspirational and has no
10
+ // source-of-truth in the repo. Returning a fabricated audit id would be a
11
+ // lie on a security surface. The honest outputs are:
12
+ // - the pinned address (DEPLOYMENTS.md)
13
+ // - the pinned grammar version (SELF_VERSION in version.rs)
14
+ // - the pinned wasm sha256 (DEPLOYMENTS.md)
15
+ // - an OPTIONAL `deployedGrammarVersion` returned by a live `grammar_version()`
16
+ // RPC call, with a `liveMatchesPin` boolean the caller can dispatch on. A
17
+ // mismatch means the deployed wasm is NOT the pinned artifact - the caller
18
+ // should refuse to install until they redeploy.
19
+ //
20
+ // The RPC plumbing lives in the run layer (`run/index.ts`); this module
21
+ // stays pure so it is testable without a network.
22
+
23
+ import type { Network } from '../types.ts'
24
+
25
+ export interface InterpreterInfo {
26
+ /** Pinned interpreter contract address. */
27
+ pinnedAddress: string
28
+ /** Pinned grammar version (matches SELF_VERSION in version.rs). */
29
+ pinnedGrammarVersion: number
30
+ /** Pinned wasm sha256 (hex). */
31
+ pinnedWasmHash: string
32
+ /** Network this pin applies to (the address + hash are network-scoped). */
33
+ network: Network
34
+ /** Present only when the caller supplied a live `deployedGrammarVersion`. */
35
+ deployedGrammarVersion?: number
36
+ /** True when `deployedGrammarVersion` matches `pinnedGrammarVersion`.
37
+ * Absent when no live verification was performed. */
38
+ liveMatchesPin?: boolean
39
+ }
40
+
41
+ /** Build the interpreter-info response. When `deployedGrammarVersion` is
42
+ * supplied (after a live RPC `grammar_version()` call by the run layer),
43
+ * compares it to the pin and sets `liveMatchesPin`. When absent, returns
44
+ * the pin alone. */
45
+ export function getInterpreterInfo(args: {
46
+ pinnedAddress: string
47
+ pinnedGrammarVersion: number
48
+ pinnedWasmHash: string
49
+ network: Network
50
+ /** When supplied, the u32 returned by the live contract's
51
+ * `grammar_version()` RPC call. */
52
+ deployedGrammarVersion?: number
53
+ }): InterpreterInfo {
54
+ const info: InterpreterInfo = {
55
+ pinnedAddress: args.pinnedAddress,
56
+ pinnedGrammarVersion: args.pinnedGrammarVersion,
57
+ pinnedWasmHash: args.pinnedWasmHash,
58
+ network: args.network,
59
+ }
60
+ if (typeof args.deployedGrammarVersion === 'number') {
61
+ return {
62
+ ...info,
63
+ deployedGrammarVersion: args.deployedGrammarVersion,
64
+ liveMatchesPin: args.deployedGrammarVersion === args.pinnedGrammarVersion,
65
+ }
66
+ }
67
+ return info
68
+ }
@@ -0,0 +1,140 @@
1
+ // Build the authorization entries OpenZeppelin's smart account requires.
2
+ //
3
+ // This is the piece that makes an end-to-end test of the account -> policy
4
+ // path possible. Without it, every attempt to exercise `__check_auth`
5
+ // produces a false positive: mocked auth skips `__check_auth` entirely,
6
+ // direct invocation is rejected by the host, and recording-mode simulation
7
+ // does not verify auth at all.
8
+ //
9
+ // Two entries are needed per call:
10
+ //
11
+ // 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
12
+ // an `AuthPayload { signers, context_rule_ids }` there, which
13
+ // `__check_auth` receives as its `signatures` argument.
14
+ //
15
+ // 2. One entry per DELEGATED signer. `do_check_auth` calls
16
+ // `addr.require_auth_for_args((auth_digest,))` for each, which is a
17
+ // nested authorization requirement the host will not record during
18
+ // simulation (it never runs `__check_auth` in recording mode), so it has
19
+ // to be constructed by hand.
20
+ //
21
+ // The digest OZ binds is NOT the raw auth payload hash:
22
+ //
23
+ // auth_digest = sha256(signature_payload || xdr(context_rule_ids))
24
+ //
25
+ // where `signature_payload` is the standard Soroban authorization preimage
26
+ // hash. See `do_check_auth` in
27
+ // stellar-contracts/packages/accounts/src/smart_account/storage.rs.
28
+
29
+ import { Address, hash, xdr } from '@stellar/stellar-sdk'
30
+
31
+ const sym = (s: string) => xdr.ScVal.scvSymbol(s)
32
+ const u32 = (n: number) => xdr.ScVal.scvU32(n)
33
+ const vec = (i: xdr.ScVal[]) => xdr.ScVal.scvVec(i)
34
+ const bytes = (b: Buffer) => xdr.ScVal.scvBytes(b)
35
+
36
+ function struct(fields: Record<string, xdr.ScVal>): xdr.ScVal {
37
+ return xdr.ScVal.scvMap(
38
+ Object.keys(fields)
39
+ .sort()
40
+ .map((k) => new xdr.ScMapEntry({ key: sym(k), val: fields[k]! }))
41
+ )
42
+ }
43
+
44
+ /** `Signer::Delegated(addr)`. */
45
+ export const delegatedSigner = (a: string) => vec([sym('Delegated'), new Address(a).toScVal()])
46
+
47
+ /** The standard Soroban authorization preimage hash for one entry. */
48
+ export function signaturePayload(
49
+ networkPassphrase: string,
50
+ nonce: xdr.Int64,
51
+ signatureExpirationLedger: number,
52
+ invocation: xdr.SorobanAuthorizedInvocation
53
+ ): Buffer {
54
+ const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
55
+ new xdr.HashIdPreimageSorobanAuthorization({
56
+ networkId: hash(Buffer.from(networkPassphrase)),
57
+ nonce,
58
+ signatureExpirationLedger,
59
+ invocation,
60
+ })
61
+ )
62
+ return hash(preimage.toXDR())
63
+ }
64
+
65
+ /**
66
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
67
+ *
68
+ * OZ binds the selected rule ids into the digest so a signature for one rule
69
+ * cannot be replayed against another.
70
+ */
71
+ export function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer {
72
+ const idsXdr = vec(contextRuleIds.map(u32)).toXDR()
73
+ return hash(Buffer.concat([payload, idsXdr]))
74
+ }
75
+
76
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
77
+ export function authPayload(
78
+ signerAddresses: string[],
79
+ contextRuleIds: number[],
80
+ signatureFor: (addr: string) => Buffer
81
+ ): xdr.ScVal {
82
+ return struct({
83
+ signers: xdr.ScVal.scvMap(
84
+ signerAddresses
85
+ .map((a) => new xdr.ScMapEntry({ key: delegatedSigner(a), val: bytes(signatureFor(a)) }))
86
+ // Host maps must be sorted by key.
87
+ .sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))
88
+ ),
89
+ context_rule_ids: vec(contextRuleIds.map(u32)),
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The nested entry a `Delegated` signer needs.
95
+ *
96
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
97
+ * `__check_auth` is running is the account contract executing `__check_auth`,
98
+ * with the digest as its single argument.
99
+ *
100
+ * The signer is the transaction source here, so source-account credentials
101
+ * carry it and no separate signature is required.
102
+ */
103
+ export function delegatedSignerEntry(
104
+ accountId: string,
105
+ digest: Buffer
106
+ ): xdr.SorobanAuthorizationEntry {
107
+ return new xdr.SorobanAuthorizationEntry({
108
+ credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
109
+ rootInvocation: new xdr.SorobanAuthorizedInvocation({
110
+ function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(
111
+ new xdr.InvokeContractArgs({
112
+ contractAddress: new Address(accountId).toScAddress(),
113
+ functionName: '__check_auth',
114
+ args: [bytes(digest)],
115
+ })
116
+ ),
117
+ subInvocations: [],
118
+ }),
119
+ })
120
+ }
121
+
122
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
123
+ export function accountEntry(
124
+ original: xdr.SorobanAuthorizationEntry,
125
+ signatureExpirationLedger: number,
126
+ payload: xdr.ScVal
127
+ ): xdr.SorobanAuthorizationEntry {
128
+ const creds = original.credentials().address()
129
+ return new xdr.SorobanAuthorizationEntry({
130
+ credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
131
+ new xdr.SorobanAddressCredentials({
132
+ address: creds.address(),
133
+ nonce: creds.nonce(),
134
+ signatureExpirationLedger,
135
+ signature: payload,
136
+ })
137
+ ),
138
+ rootInvocation: original.rootInvocation(),
139
+ })
140
+ }
package/src/run/index.ts CHANGED
@@ -17,9 +17,12 @@
17
17
  // No business logic. No retries. No session state. The same call shape can
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
 
20
+ import { createHash } from 'node:crypto'
21
+ import { rpc } from '@stellar/stellar-sdk'
20
22
  import {
21
23
  type ErrorCode,
22
24
  type MandateSpec,
25
+ type Network,
23
26
  type OzAdapterConfig,
24
27
  type PredicateNode,
25
28
  type ProposedPolicy,
@@ -32,35 +35,83 @@ import {
32
35
  type ToolError,
33
36
  type ToolResponse,
34
37
  } from '../index.ts'
38
+ import {
39
+ type BuildInstallPolicyResult,
40
+ type BuildRevokePolicyResult,
41
+ buildInstallPolicyXdr,
42
+ buildRevokePolicyXdr,
43
+ type InstallRpcClient,
44
+ rpcClientFromServer,
45
+ } from '../install/build-install-policy.ts'
46
+ import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
35
47
  import type { SimulationResult } from '../verify/envelope.ts'
48
+ import { simulatePolicy, verifyPolicy } from '../verify/index.ts'
36
49
  import {
50
+ type GetInterpreterInfoInput,
51
+ GetInterpreterInfoInputSchema,
52
+ type InstallPolicyInput,
53
+ InstallPolicyInputSchema,
54
+ NETWORK_PASSPHRASES,
55
+ PINNED_INTERPRETER_GRAMMAR_VERSION,
56
+ PINNED_INTERPRETER_TESTNET_ADDRESS,
57
+ PINNED_INTERPRETER_WASM_SHA256,
37
58
  type RecordTransactionInput,
38
59
  RecordTransactionInputSchema,
60
+ type RevokePolicyInput,
61
+ RevokePolicyInputSchema,
62
+ type SimulatePolicyInput,
63
+ SimulatePolicyInputSchema,
39
64
  type SynthesizePolicyInput,
40
65
  SynthesizePolicyInputSchema,
66
+ TESTNET_RPC_URL,
67
+ type VerifyPolicyInput,
68
+ VerifyPolicyInputSchema,
41
69
  } from './schemas.ts'
42
70
 
43
- export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts'
71
+ export type {
72
+ GetInterpreterInfoInput,
73
+ InstallPolicyInput,
74
+ RecordTransactionInput,
75
+ RevokePolicyInput,
76
+ SimulatePolicyInput,
77
+ SynthesizePolicyInput,
78
+ VerifyPolicyInput,
79
+ } from './schemas.ts'
44
80
  // Re-export the underlying Zod schemas so the MCP package (and any other
45
81
  // downstream consumer) can import the canonical input shapes from the same
46
82
  // module that owns the tool-body glue. The strict schemas are the source of
47
83
  // truth - MCP tool shapes are derived from them.
48
84
  export {
49
85
  ComposeUserResponsesSchema,
86
+ GetInterpreterInfoInputSchema,
87
+ InstallPolicyInputSchema,
50
88
  InterpreterOptionsSchema,
51
89
  MandateSpecSchema,
52
90
  NetworkSchema,
91
+ OraclePriceFixtureSchema,
53
92
  OzAdapterConfigSchema,
93
+ PINNED_INTERPRETER_GRAMMAR_VERSION,
94
+ PINNED_INTERPRETER_TESTNET_ADDRESS,
95
+ PINNED_INTERPRETER_WASM_SHA256,
96
+ PredicateLeafSchema,
97
+ PredicateNodeSchema,
54
98
  RecordedTransactionSchema,
55
99
  RecordTransactionInputSchema,
100
+ RevokePolicyInputSchema,
101
+ SimulatePolicyInputSchema,
56
102
  SynthesizePolicyInputSchema,
57
103
  ToolErrorSchema,
104
+ VerifyPolicyInputSchema,
58
105
  } from './schemas.ts'
59
106
 
60
107
  export type RunRecordTransactionInput = RecordTransactionInput
61
108
 
62
109
  export type RunSynthesizePolicyInput = SynthesizePolicyInput
63
110
 
111
+ export type RunSimulatePolicyInput = SimulatePolicyInput
112
+
113
+ export type RunVerifyPolicyInput = VerifyPolicyInput
114
+
64
115
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
65
116
  * matches the core RecordInput minus the injected `fetcher` (the transport
66
117
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -173,14 +224,353 @@ function resolveOzConfig(input: SynthesizePolicyInput): OzAdapterConfig {
173
224
  return placeholderOzConfig('mainnet')
174
225
  }
175
226
 
227
+ /** `simulate_policy` body - thin wrapper over `simulatePolicy`. The engine
228
+ * already returns fail-closed `{ok:false, error}` for runtime failures
229
+ * (SIMULATION_ERROR), so the try/catch envelope is for raw SDK throws
230
+ * only - same pattern as the other two wrappers. The predicate is
231
+ * passed inline (stateless by design; no `proposed_policy_id` lookup). */
232
+ export async function runSimulatePolicy(raw: unknown): Promise<ToolResponse<SimulationResult>> {
233
+ const parsed = SimulatePolicyInputSchema.safeParse(raw)
234
+ if (!parsed.success) {
235
+ return {
236
+ ok: false,
237
+ error: validationError('simulate_policy', parsed.error.issues),
238
+ }
239
+ }
240
+ const input: SimulatePolicyInput = parsed.data
241
+ try {
242
+ // The recursive PredicateNodeSchema + ContractInvocationSchema are
243
+ // typed `z.ZodType<unknown>` to survive TS's circular inference; the
244
+ // engine wants typed `PredicateNode | null` + `RecordedTransaction`.
245
+ // The schema already validated the shape, so assert through the
246
+ // unknown back to the core types. Same pattern as the recordedTx
247
+ // cast in `runSynthesizePolicy`.
248
+ return simulatePolicy(
249
+ input.predicate as PredicateNode | null,
250
+ input.permitTx as RecordedTransaction,
251
+ {
252
+ ...(input.validUntilLedger !== undefined
253
+ ? { validUntilLedger: input.validUntilLedger }
254
+ : {}),
255
+ ...(input.oraclePricesByAsset !== undefined
256
+ ? { oraclePricesByAsset: input.oraclePricesByAsset }
257
+ : {}),
258
+ }
259
+ )
260
+ } catch (e) {
261
+ return {
262
+ ok: false,
263
+ error: caughtError('simulate_policy', 'SIMULATION_ERROR', e),
264
+ }
265
+ }
266
+ }
267
+
268
+ /** `verify_policy` body - thin wrapper over `verifyPolicy`. The engine
269
+ * already returns `{ok:false, error}` with code VERIFICATION_FAILED when
270
+ * the minimality check fails; the try/catch envelope is for raw SDK
271
+ * throws only. Mirrors `runSimulatePolicy` exactly. */
272
+ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>> {
273
+ const parsed = VerifyPolicyInputSchema.safeParse(raw)
274
+ if (!parsed.success) {
275
+ return {
276
+ ok: false,
277
+ error: validationError('verify_policy', parsed.error.issues),
278
+ }
279
+ }
280
+ const input: VerifyPolicyInput = parsed.data
281
+ try {
282
+ // Same cast as runSimulatePolicy: the recursive schemas are typed
283
+ // `unknown`; the engine wants typed `PredicateNode` +
284
+ // `RecordedTransaction`. The schema already validated the shape.
285
+ return verifyPolicy(input.predicate as PredicateNode, input.permitTx as RecordedTransaction, {
286
+ ...(input.validUntilLedger !== undefined ? { validUntilLedger: input.validUntilLedger } : {}),
287
+ ...(input.oraclePricesByAsset !== undefined
288
+ ? { oraclePricesByAsset: input.oraclePricesByAsset }
289
+ : {}),
290
+ })
291
+ } catch (e) {
292
+ return {
293
+ ok: false,
294
+ error: caughtError('verify_policy', 'VERIFICATION_FAILED', e),
295
+ }
296
+ }
297
+ }
298
+
299
+ /** `install_policy` body - thin wrapper over `buildInstallPolicyXdr`.
300
+ * Returns the unsigned Soroban transaction envelope (base64 XDR) the
301
+ * wallet signs. The wallet signature IS the user-confirmation step - no
302
+ * `action_id` two-call pair (the server is stateless, see server.ts:10-12).
303
+ * Per design decision 4, only CALL 1 (account.add_context_rule) is
304
+ * emitted; CALL 2 (interpreter.install) requires the rule id the account
305
+ * assigns in call 1 and is documented under `followUp` in the response.
306
+ *
307
+ * Default-deny: an interpreter policy address other than the pinned
308
+ * testnet interpreter is REFUSED (the smart account would delegate to
309
+ * an interpreter the caller controls); the same applies to a non-pinned
310
+ * RPC URL (the auth nonce the wallet signs comes from the RPC). Both
311
+ * gates accept an explicit opt-in flag. */
312
+ export async function runInstallPolicy(
313
+ raw: unknown
314
+ ): Promise<ToolResponse<BuildInstallPolicyResult>> {
315
+ const parsed = InstallPolicyInputSchema.safeParse(raw)
316
+ if (!parsed.success) {
317
+ return {
318
+ ok: false,
319
+ error: validationError('install_policy', parsed.error.issues),
320
+ }
321
+ }
322
+ const input: InstallPolicyInput = parsed.data
323
+ // ---- Pinning gates (default-deny) ----
324
+ const pinningError = enforceInterpreterPin(input.rule.policies, input.allowUnpinnedInterpreter)
325
+ if (pinningError) {
326
+ return { ok: false, error: pinningError }
327
+ }
328
+ if (input.rpcUrl && input.rpcUrl !== TESTNET_RPC_URL && input.allowUnpinnedRpcUrl !== true) {
329
+ return {
330
+ ok: false,
331
+ error: {
332
+ code: 'INSTALL_BUILD_FAILED',
333
+ message: `install_policy: rpcUrl must equal the pinned TESTNET_RPC_URL; set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
334
+ severity: 'error',
335
+ retryable: false,
336
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
337
+ },
338
+ }
339
+ }
340
+ let rpcClient: InstallRpcClient
341
+ try {
342
+ rpcClient = buildRpcClientFromInput(input.rpcUrl)
343
+ } catch (e) {
344
+ return {
345
+ ok: false,
346
+ error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e),
347
+ }
348
+ }
349
+ try {
350
+ const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter')
351
+ const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? ''
352
+ const predicateHash = createHash('sha256')
353
+ .update(Buffer.from(encodedPredicate, 'base64'))
354
+ .digest('hex')
355
+ const result = await buildInstallPolicyXdr({
356
+ smartAccount: input.smartAccount,
357
+ sourceAccount: input.sourceAccount,
358
+ networkPassphrase: NETWORK_PASSPHRASES.testnet,
359
+ rule: input.rule,
360
+ installNonce: input.installNonce,
361
+ encodedPredicate,
362
+ predicateHash,
363
+ rpc: rpcClient,
364
+ ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
365
+ })
366
+ return { ok: true, data: result }
367
+ } catch (e) {
368
+ return {
369
+ ok: false,
370
+ error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e),
371
+ }
372
+ }
373
+ }
374
+
375
+ /** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
376
+ * Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
377
+ * smart account itself handles uninstalling each attached policy. Auth
378
+ * is master-only; the source account MUST be the master signer set.
379
+ *
380
+ * Same RPC pin as install: a non-pinned `rpcUrl` is refused unless
381
+ * `allowUnpinnedRpcUrl: true`. Revoke does not carry an interpreter
382
+ * policy payload, so the interpreter pin is not re-checked here. */
383
+ export async function runRevokePolicy(
384
+ raw: unknown
385
+ ): Promise<ToolResponse<BuildRevokePolicyResult>> {
386
+ const parsed = RevokePolicyInputSchema.safeParse(raw)
387
+ if (!parsed.success) {
388
+ return {
389
+ ok: false,
390
+ error: validationError('revoke_policy', parsed.error.issues),
391
+ }
392
+ }
393
+ const input: RevokePolicyInput = parsed.data
394
+ if (input.rpcUrl && input.rpcUrl !== TESTNET_RPC_URL && input.allowUnpinnedRpcUrl !== true) {
395
+ return {
396
+ ok: false,
397
+ error: {
398
+ code: 'REVOKE_BUILD_FAILED',
399
+ message: `revoke_policy: rpcUrl must equal the pinned TESTNET_RPC_URL; set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
400
+ severity: 'error',
401
+ retryable: false,
402
+ remediation: { toolCall: { name: 'revoke_policy', args: {} } },
403
+ },
404
+ }
405
+ }
406
+ let rpcClient: InstallRpcClient
407
+ try {
408
+ rpcClient = buildRpcClientFromInput(input.rpcUrl)
409
+ } catch (e) {
410
+ return {
411
+ ok: false,
412
+ error: caughtError('revoke_policy', 'REVOKE_BUILD_FAILED', e),
413
+ }
414
+ }
415
+ try {
416
+ const result = await buildRevokePolicyXdr({
417
+ smartAccount: input.smartAccount,
418
+ sourceAccount: input.sourceAccount,
419
+ ruleId: input.ruleId,
420
+ networkPassphrase: NETWORK_PASSPHRASES.testnet,
421
+ rpc: rpcClient,
422
+ ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
423
+ })
424
+ return { ok: true, data: result }
425
+ } catch (e) {
426
+ return {
427
+ ok: false,
428
+ error: caughtError('revoke_policy', 'REVOKE_BUILD_FAILED', e),
429
+ }
430
+ }
431
+ }
432
+
433
+ /** `get_interpreter_info` body - thin wrapper over `getInterpreterInfo`.
434
+ * Returns the pinned deployment fingerprint + an optional live
435
+ * `grammar_version()` comparison. The audit field is deliberately
436
+ * OMITTED (phase-04's "audit #44" has no source of truth in the repo -
437
+ * fabricating it would be a lie on a security surface; the live
438
+ * mismatch check is worth MORE). */
439
+ export async function runGetInterpreterInfo(
440
+ raw: unknown
441
+ ): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>> {
442
+ const parsed = GetInterpreterInfoInputSchema.safeParse(raw)
443
+ if (!parsed.success) {
444
+ return {
445
+ ok: false,
446
+ error: validationError('get_interpreter_info', parsed.error.issues),
447
+ }
448
+ }
449
+ const input: GetInterpreterInfoInput = parsed.data
450
+ const network: Network = input.network ?? 'testnet'
451
+ // Asking about mainnet used to return the TESTNET pin with `network:
452
+ // 'mainnet'` stamped on it. DEPLOYMENTS.md:3 says plainly "Testnet only.
453
+ // Nothing is deployed to mainnet yet", so that answer described a contract
454
+ // that does not exist at an address that is not on that network. This tool
455
+ // exists to tell a caller what they are installing against; inventing a
456
+ // mainnet deployment is the same failure as inventing an audit reference.
457
+ if (network === 'mainnet') {
458
+ return {
459
+ ok: false,
460
+ error: {
461
+ code: 'RECORDING_FAILED',
462
+ message:
463
+ 'get_interpreter_info: no interpreter is deployed to mainnet. The pinned address and wasm hash are testnet-only; mainnet deployment is gated on the interpreter audit.',
464
+ severity: 'error',
465
+ retryable: false,
466
+ remediation: {
467
+ toolCall: { name: 'get_interpreter_info', args: { network: 'testnet' } },
468
+ },
469
+ },
470
+ }
471
+ }
472
+ try {
473
+ let deployedGrammarVersion: number | undefined
474
+ if (input.verifyLive === true) {
475
+ const rpcClient = buildRpcClientFromInput(input.rpcUrl)
476
+ deployedGrammarVersion = await rpcClient.getContractVersion(
477
+ PINNED_INTERPRETER_TESTNET_ADDRESS
478
+ )
479
+ }
480
+ const info = getInterpreterInfo({
481
+ pinnedAddress: PINNED_INTERPRETER_TESTNET_ADDRESS,
482
+ pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
483
+ pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
484
+ network,
485
+ ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
486
+ })
487
+ return { ok: true, data: info }
488
+ } catch (e) {
489
+ return {
490
+ ok: false,
491
+ error: caughtError('get_interpreter_info', 'RECORDING_FAILED', e),
492
+ }
493
+ }
494
+ }
495
+
496
+ /** Build an InstallRpcClient from an optional URL override, falling back
497
+ * to the public testnet RPC (matching the design-decision rule that we
498
+ * reuse `record/rpc.ts` and never hard-code a different RPC client). */
499
+ function buildRpcClientFromInput(urlOverride?: string): InstallRpcClient {
500
+ if (urlOverride) {
501
+ return rpcClientFromServer(
502
+ new rpc.Server(urlOverride, { allowHttp: false }),
503
+ NETWORK_PASSPHRASES.testnet
504
+ )
505
+ }
506
+ // Default: public testnet RPC. Matches the brief; the mainnet pin would
507
+ // need a separate deploy and is out of scope for this tool.
508
+ //
509
+ // NOT `createRpcServer` - that returns an RpcFetcher, a bare
510
+ // `(hash) => Promise<SorobanTxResponse|null>` for the RECORDER. Passing it
511
+ // here produced a client whose `getAccount` was undefined, so every live
512
+ // call died with "server.getAccount is not a function". The install path
513
+ // needs the full Server surface.
514
+ return rpcClientFromServer(
515
+ new rpc.Server(TESTNET_RPC_URL, { allowHttp: false }),
516
+ NETWORK_PASSPHRASES.testnet
517
+ )
518
+ }
519
+
520
+ /** Default-deny: refuse any interpreter policy whose address differs from
521
+ * the pinned testnet interpreter. An interpreter the caller controls
522
+ * can permit anything, so the smart account's authorization must bind
523
+ * to the pinned contract unless the caller explicitly opts in via
524
+ * `allowUnpinnedInterpreter`. OZ built-in policies are not interpreters
525
+ * and pass through unchanged. Returns a ToolError to surface through
526
+ * the run-layer envelope, or null when the policies are all pinned. */
527
+ function enforceInterpreterPin(
528
+ policies: InstallPolicyInput['rule']['policies'],
529
+ allowUnpinned: boolean | undefined
530
+ ): ToolError | null {
531
+ for (const p of policies) {
532
+ if (p.kind !== 'interpreter') continue
533
+ if (p.interpreterAddress === PINNED_INTERPRETER_TESTNET_ADDRESS) continue
534
+ if (allowUnpinned === true) continue
535
+ return {
536
+ code: 'INSTALL_BUILD_FAILED',
537
+ message: `install_policy: interpreter policy address ${p.interpreterAddress} != pinned ${PINNED_INTERPRETER_TESTNET_ADDRESS}; set allowUnpinnedInterpreter: true to opt in to a non-pinned interpreter`,
538
+ severity: 'error',
539
+ retryable: false,
540
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
541
+ }
542
+ }
543
+ return null
544
+ }
545
+
176
546
  /** Build a canonical ToolError for a Zod validation failure. The remediation
177
547
  * hint points the agent back at the right tool with an empty arg bag - the
178
548
  * tool name IS the machine-readable hint. */
179
549
  function validationError(
180
- toolName: 'record_transaction' | 'synthesize_policy',
550
+ toolName:
551
+ | 'record_transaction'
552
+ | 'synthesize_policy'
553
+ | 'simulate_policy'
554
+ | 'verify_policy'
555
+ | 'install_policy'
556
+ | 'revoke_policy'
557
+ | 'get_interpreter_info',
181
558
  issues: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>
182
559
  ): ToolError {
183
- const code: ErrorCode = toolName === 'record_transaction' ? 'RECORDING_FAILED' : 'SYNTHESIS_ERROR'
560
+ const code: ErrorCode =
561
+ toolName === 'record_transaction'
562
+ ? 'RECORDING_FAILED'
563
+ : toolName === 'synthesize_policy'
564
+ ? 'SYNTHESIS_ERROR'
565
+ : toolName === 'simulate_policy'
566
+ ? 'SIMULATION_ERROR'
567
+ : toolName === 'verify_policy'
568
+ ? 'VERIFICATION_FAILED'
569
+ : toolName === 'install_policy'
570
+ ? 'INSTALL_BUILD_FAILED'
571
+ : toolName === 'revoke_policy'
572
+ ? 'REVOKE_BUILD_FAILED'
573
+ : 'RECORDING_FAILED'
184
574
  return {
185
575
  code,
186
576
  message: `${toolName}: invalid input: ${issues
@@ -206,7 +596,14 @@ function validationError(
206
596
  * seam) so the suite in run/index.test.ts can drive the envelope path
207
597
  * without standing up a full recordTransaction pipeline. */
208
598
  export function caughtError(
209
- toolName: 'record_transaction' | 'synthesize_policy',
599
+ toolName:
600
+ | 'record_transaction'
601
+ | 'synthesize_policy'
602
+ | 'simulate_policy'
603
+ | 'verify_policy'
604
+ | 'install_policy'
605
+ | 'revoke_policy'
606
+ | 'get_interpreter_info',
210
607
  code: ErrorCode,
211
608
  e: unknown
212
609
  ): ToolError {
@@ -270,8 +667,11 @@ function safeStringify(v: unknown): string {
270
667
  (_k, value) => {
271
668
  if (typeof value === 'bigint') return value.toString()
272
669
  if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`
670
+ // `stack` is a server-controlled diagnostic. Stack traces from a host
671
+ // we do not own are reconnaissance, not a signal the caller can act on.
672
+ // Surface `name` + `message` only.
273
673
  if (value instanceof Error) {
274
- return { name: value.name, message: value.message, stack: value.stack }
674
+ return { name: value.name, message: value.message }
275
675
  }
276
676
  if (value !== null && typeof value === 'object') {
277
677
  if (seen.has(value)) return '[Circular]'