@crediolabs/policy-synth 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/interpreter/adapter.js +21 -0
- package/dist/adapters/oz/adapter.js +4 -0
- package/dist/install/build-install-policy.d.ts +218 -0
- package/dist/install/build-install-policy.js +427 -0
- package/dist/install/get-interpreter-info.d.ts +29 -0
- package/dist/install/get-interpreter-info.js +41 -0
- package/dist/ir/types.d.ts +19 -0
- package/dist/predicate/index.d.ts +1 -1
- package/dist/predicate/index.js +1 -1
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +20 -0
- package/dist/run/index.d.ts +49 -4
- package/dist/run/index.js +324 -4
- package/dist/run/schemas.d.ts +1500 -4
- package/dist/run/schemas.js +288 -0
- package/dist/synth/compose-from-recording.d.ts +14 -0
- package/dist/synth/compose-from-recording.js +41 -2
- package/dist-cjs/adapters/interpreter/adapter.js +21 -0
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- package/dist-cjs/install/build-install-policy.d.ts +218 -0
- package/dist-cjs/install/build-install-policy.js +431 -0
- package/dist-cjs/install/get-interpreter-info.d.ts +29 -0
- package/dist-cjs/install/get-interpreter-info.js +44 -0
- package/dist-cjs/ir/types.d.ts +19 -0
- package/dist-cjs/predicate/index.d.ts +1 -1
- package/dist-cjs/predicate/index.js +3 -3
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +20 -0
- package/dist-cjs/run/index.d.ts +49 -4
- package/dist-cjs/run/index.js +339 -3
- package/dist-cjs/run/schemas.d.ts +1500 -4
- package/dist-cjs/run/schemas.js +289 -1
- package/dist-cjs/synth/compose-from-recording.d.ts +14 -0
- package/dist-cjs/synth/compose-from-recording.js +41 -2
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +21 -0
- package/src/adapters/oz/adapter.ts +4 -0
- package/src/install/build-install-policy.ts +686 -0
- package/src/install/get-interpreter-info.ts +68 -0
- package/src/ir/types.ts +19 -0
- package/src/predicate/index.ts +1 -1
- package/src/review-card/builder.ts +27 -0
- package/src/review-card/cross-check.ts +25 -1
- package/src/run/index.ts +405 -5
- package/src/run/schemas.ts +326 -0
- package/src/synth/compose-from-recording.ts +55 -0
- package/src/synth/evaluate.ts +0 -1
- package/src/synth/permit-context.ts +0 -1
|
@@ -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
|
+
}
|
package/src/ir/types.ts
CHANGED
|
@@ -86,6 +86,25 @@ export type IRCondition =
|
|
|
86
86
|
* does. Adapters that cannot express an exact ordered vector (OZ built-ins)
|
|
87
87
|
* flag this as `uncovered` rather than silently dropping it. */
|
|
88
88
|
| { op: 'eq_seq'; selector: IRSelector; values: string[] }
|
|
89
|
+
/** A swap's output floor, expressed against its own input: the output arg
|
|
90
|
+
* must be at least `inArgIndex * num / den`.
|
|
91
|
+
*
|
|
92
|
+
* It is its own construct because `compare` bounds a selector against a
|
|
93
|
+
* CONSTANT, and a slippage floor has no constant to bound against - the
|
|
94
|
+
* acceptable output depends on the input of the same call. Encoding the
|
|
95
|
+
* recorded output as a constant would pin a policy to one trade size.
|
|
96
|
+
*
|
|
97
|
+
* `num/den` is the caller's minimum acceptable output per unit of input.
|
|
98
|
+
* It is never derived from the recording: the recorded rate is a price at
|
|
99
|
+
* one moment, and freezing it as policy would deny normal trades later.
|
|
100
|
+
* Adapters that cannot express it (the OZ built-ins) flag it `uncovered`. */
|
|
101
|
+
| {
|
|
102
|
+
op: 'slippage_floor'
|
|
103
|
+
outArgIndex: number
|
|
104
|
+
inArgIndex: number
|
|
105
|
+
num: string
|
|
106
|
+
den: string
|
|
107
|
+
}
|
|
89
108
|
|
|
90
109
|
export interface IRPolicyRule {
|
|
91
110
|
/** NEAR-V2 roles whitelist (empty = any; owner exempt). */
|
package/src/predicate/index.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
3
3
|
// policy needs both halves.
|
|
4
4
|
|
|
5
|
-
export { type EncodedPredicate, encodePredicate } from './encode.ts'
|
|
6
5
|
export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts'
|
|
6
|
+
export { type EncodedPredicate, encodePredicate } from './encode.ts'
|
|
7
7
|
export { jsonToAst } from './from-json.ts'
|
|
@@ -222,6 +222,33 @@ function renderComparison(
|
|
|
222
222
|
return `At most ${allowed} calls per ${left.windowSecs} seconds`
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
226
|
+
//
|
|
227
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
228
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
229
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
230
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
231
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
232
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
233
|
+
// silently understated the policy.
|
|
234
|
+
//
|
|
235
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
236
|
+
// reads as a path rather than as a comparison.
|
|
237
|
+
if (left.kind === 'call_arg') {
|
|
238
|
+
const head = `arg[${left.index}]`
|
|
239
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
|
|
240
|
+
if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
|
|
241
|
+
if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
|
|
242
|
+
if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
|
|
243
|
+
if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
|
|
244
|
+
if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
|
|
245
|
+
if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
|
|
246
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
247
|
+
if (right.kind === 'call_arg_scaled') {
|
|
248
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
// amount <= v -> Amount <= v
|
|
226
253
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
227
254
|
return `Amount <= ${right.value}`
|
|
@@ -125,12 +125,36 @@ function pushComparison(
|
|
|
125
125
|
out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`)
|
|
126
126
|
return
|
|
127
127
|
}
|
|
128
|
+
// Mirrors the builder's per-call cap line. Both sides must render the same
|
|
129
|
+
// string: cross-check compares the summary against the predicate, so a
|
|
130
|
+
// shape only ONE of them renders would read as a dropped constraint.
|
|
131
|
+
if (left.kind === 'call_arg') {
|
|
132
|
+
const head = `arg[${left.index}]`
|
|
133
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
134
|
+
if (
|
|
135
|
+
right.kind === 'literal_i128' ||
|
|
136
|
+
right.kind === 'literal_u64' ||
|
|
137
|
+
right.kind === 'literal_u32' ||
|
|
138
|
+
right.kind === 'literal_address' ||
|
|
139
|
+
right.kind === 'literal_symbol' ||
|
|
140
|
+
right.kind === 'literal_bytes'
|
|
141
|
+
) {
|
|
142
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (right.kind === 'call_arg_scaled') {
|
|
146
|
+
out.push(`${head} >= arg[${right.index}] * ${right.num}/${right.den}`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
}
|
|
128
150
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
129
151
|
out.push(`Amount <= ${right.value}`)
|
|
130
152
|
return
|
|
131
153
|
}
|
|
132
154
|
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
133
|
-
out.push(
|
|
155
|
+
out.push(
|
|
156
|
+
`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`
|
|
157
|
+
)
|
|
134
158
|
return
|
|
135
159
|
}
|
|
136
160
|
}
|
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 {
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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]'
|