@bondedhq/sdk 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +482 -2
- package/dist/actions.d.ts +76 -0
- package/dist/actions.js +115 -0
- package/dist/agent-url.d.ts +101 -0
- package/dist/agent-url.js +234 -0
- package/dist/agentkit.d.ts +104 -0
- package/dist/agentkit.js +206 -0
- package/dist/ai-sdk.d.ts +39 -0
- package/dist/ai-sdk.js +86 -0
- package/dist/breaches.d.ts +50 -0
- package/dist/breaches.js +57 -0
- package/dist/done.d.ts +14 -0
- package/dist/done.js +16 -0
- package/dist/eliza.d.ts +118 -0
- package/dist/eliza.js +281 -0
- package/dist/episode.d.ts +108 -0
- package/dist/episode.js +65 -0
- package/dist/errors.d.ts +93 -0
- package/dist/errors.js +97 -0
- package/dist/guarded.d.ts +80 -0
- package/dist/guarded.js +97 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/json-schema-zod3.d.ts +9 -0
- package/dist/json-schema-zod3.js +54 -0
- package/dist/langchain.d.ts +39 -0
- package/dist/langchain.js +53 -0
- package/dist/mandate.d.ts +36 -0
- package/dist/mandate.js +26 -0
- package/dist/mcp-config-cli.d.ts +3 -0
- package/dist/mcp-config-cli.js +44 -0
- package/dist/mcp.d.ts +131 -0
- package/dist/mcp.js +111 -0
- package/dist/module.d.ts +37 -0
- package/dist/module.js +32 -0
- package/dist/revert.d.ts +12 -0
- package/dist/revert.js +71 -0
- package/dist/sentinel.d.ts +43 -0
- package/dist/sentinel.js +94 -0
- package/dist/signature.d.ts +34 -0
- package/dist/signature.js +52 -0
- package/dist/solana-agent-kit.d.ts +141 -0
- package/dist/solana-agent-kit.js +333 -0
- package/dist/vault-tools.d.ts +67 -0
- package/dist/vault-tools.js +335 -0
- package/dist/vault.d.ts +116 -0
- package/dist/vault.js +326 -0
- package/package.json +134 -3
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { BondedError } from "./errors.js";
|
|
4
|
+
import { finishEpisode } from "./done.js";
|
|
5
|
+
import { fromBondedEnv } from "./episode.js";
|
|
6
|
+
export { isBondedEpisode } from "./episode.js";
|
|
7
|
+
export { finishEpisode } from "./done.js";
|
|
8
|
+
/**
|
|
9
|
+
* Connects to the episode's MCP endpoint and lists its tools. With no argument it reads the
|
|
10
|
+
* episode from the runner's `BONDED_*` variables (`fromBondedEnv`); in Agent URL mode pass the
|
|
11
|
+
* episode your handler received.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const episode = await connectEpisodeTools();
|
|
15
|
+
* for (const tool of episode.tools) console.log(tool.name, tool.inputSchema);
|
|
16
|
+
* const prices = await episode.callTool("get_prices");
|
|
17
|
+
* await episode.done({ finalMessage: "bought tNVDA" });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export async function connectEpisodeTools(episode = fromBondedEnv(), options = {}) {
|
|
21
|
+
const http = options.fetch ?? fetch;
|
|
22
|
+
const client = new Client({ name: options.clientName ?? "bonded-sdk", version: "1" });
|
|
23
|
+
const transport = new StreamableHTTPClientTransport(new URL(episode.endpoints.mcp), {
|
|
24
|
+
fetch: http,
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
await client.connect(transport);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new BondedError("EPISODE_TOOLS", `couldn't connect to the episode's MCP endpoint: ${error instanceof Error ? error.message : String(error)}`);
|
|
31
|
+
}
|
|
32
|
+
options.signal?.addEventListener("abort", () => void client.close(), { once: true });
|
|
33
|
+
const callTool = async (name, args = {}) => {
|
|
34
|
+
const result = await client.callTool({ name, arguments: args });
|
|
35
|
+
return readResult(result);
|
|
36
|
+
};
|
|
37
|
+
const listed = await client.listTools();
|
|
38
|
+
const tools = listed.tools.map((t) => ({
|
|
39
|
+
name: t.name,
|
|
40
|
+
description: t.description ?? "",
|
|
41
|
+
inputSchema: { ...t.inputSchema, type: "object" },
|
|
42
|
+
call: (args) => callTool(t.name, args),
|
|
43
|
+
}));
|
|
44
|
+
return {
|
|
45
|
+
client,
|
|
46
|
+
tools,
|
|
47
|
+
listTools: () => client.listTools(),
|
|
48
|
+
callTool,
|
|
49
|
+
done: (body) => finishEpisode(episode, body, { fetch: http }),
|
|
50
|
+
close: () => client.close(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The Arena's `ToolResult`, from MCP's structured content when present, else the text. */
|
|
54
|
+
function readResult(result) {
|
|
55
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
56
|
+
const text = content
|
|
57
|
+
.filter((c) => c.type === "text")
|
|
58
|
+
.map((c) => c.text)
|
|
59
|
+
.join("\n");
|
|
60
|
+
const structured = result.structuredContent;
|
|
61
|
+
if (structured && typeof structured.ok === "boolean")
|
|
62
|
+
return structured.ok
|
|
63
|
+
? { ok: true, output: structured.output, text }
|
|
64
|
+
: { ok: false, error: structured.error, text: structured.error };
|
|
65
|
+
if (result.isError)
|
|
66
|
+
return { ok: false, error: text, text };
|
|
67
|
+
let output = text;
|
|
68
|
+
try {
|
|
69
|
+
output = JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Plain text output.
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, output, text };
|
|
75
|
+
}
|
|
76
|
+
/** The text a string-returning framework (LangChain, AgentKit) gives its model. */
|
|
77
|
+
export function resultText(result) {
|
|
78
|
+
return result.ok ? result.text : `Error: ${result.error}`;
|
|
79
|
+
}
|
|
80
|
+
/** Tools from a connection, or a fresh connection to the episode. */
|
|
81
|
+
export async function resolveTools(from) {
|
|
82
|
+
if (from && "callTool" in from && "tools" in from)
|
|
83
|
+
return from;
|
|
84
|
+
return connectEpisodeTools(from ?? fromBondedEnv());
|
|
85
|
+
}
|
|
86
|
+
/** The `mcp-remote` bridge, pinned, for MCP clients that only start local (stdio) servers. */
|
|
87
|
+
export const MCP_REMOTE_PACKAGE = "mcp-remote@0.14.3";
|
|
88
|
+
/**
|
|
89
|
+
* The `mcpServers` block an MCP-aware agent accepts, pointing at this episode's MCP endpoint.
|
|
90
|
+
* The URL carries the episode's capability and stops working when the episode ends, so write the
|
|
91
|
+
* config afresh for every episode (e.g. at the start of the runner's agent command):
|
|
92
|
+
*
|
|
93
|
+
* ```sh
|
|
94
|
+
* bonded-runner --command 'bonded-mcp-config > .mcp.json && my-agent --mcp-config .mcp.json'
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function mcpConfig(episode = fromBondedEnv(), options = {}) {
|
|
98
|
+
const url = episode.endpoints.mcp;
|
|
99
|
+
const style = options.style ?? "http";
|
|
100
|
+
const entry = style === "stdio"
|
|
101
|
+
? { command: "npx", args: ["-y", MCP_REMOTE_PACKAGE, url] }
|
|
102
|
+
: style === "url"
|
|
103
|
+
? { url }
|
|
104
|
+
: { type: "http", url };
|
|
105
|
+
return { mcpServers: { [options.name ?? "bonded-episode"]: entry } };
|
|
106
|
+
}
|
|
107
|
+
/** `mcpConfig` as pretty-printed JSON, ready to write to a file. */
|
|
108
|
+
export function mcpConfigJson(episode = fromBondedEnv(), options = {}) {
|
|
109
|
+
return `${JSON.stringify(mcpConfig(episode, options), null, 2)}\n`;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=mcp.js.map
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ContractReader, type EvmMandate, type EvmMandateRules, type ModuleAccountType, type ModuleInstallPlan } from "@bondedhq/shared";
|
|
2
|
+
import type { Address } from "viem";
|
|
3
|
+
export type { ModuleAccountType, ModuleInstallPlan };
|
|
4
|
+
/**
|
|
5
|
+
* Approvals a module can't see: before installing (and while installed), list the account's
|
|
6
|
+
* allowances to the mandate's targets for tokens the mandate doesn't cover, and revoke them.
|
|
7
|
+
* See `checkUnprotectedAllowances` in @bondedhq/shared.
|
|
8
|
+
*/
|
|
9
|
+
export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, type AllowanceCheck, type AllowanceFinding, type TokenRef, } from "@bondedhq/shared";
|
|
10
|
+
export interface InstallMandateModuleInput {
|
|
11
|
+
/** MandateModuleFactory (`moduleFactory` in the deployment file). */
|
|
12
|
+
moduleFactory: Address;
|
|
13
|
+
/** The smart account to protect; it sends the batch, so it becomes the module's account. */
|
|
14
|
+
account: Address;
|
|
15
|
+
/** "safe7579" for a Safe with Safe7579; "erc7579" for an account like the reference one. */
|
|
16
|
+
accountType: ModuleAccountType;
|
|
17
|
+
/** The mandate in plain rules (dollars, percentages), as for `buildMandate`. */
|
|
18
|
+
rules: EvmMandateRules;
|
|
19
|
+
/** The agent's session key: the only key that can call the module's `execute`. */
|
|
20
|
+
agent: Address;
|
|
21
|
+
/** Can freeze, unfreeze and rotate the agent; must own the agent id to buy cover. */
|
|
22
|
+
guardian: Address;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Everything a smart account needs to put an agent's mandate on itself in one transaction:
|
|
26
|
+
* create its MandateModule through the factory, and install it as executor (type 2) and hook
|
|
27
|
+
* (type 4). Nothing is sent: the account's owner sends `plan.calls` as a Safe MultiSend from
|
|
28
|
+
* the Safe, or `plan.execute.data` to an ERC-7579 account (e.g. as a user operation).
|
|
29
|
+
*
|
|
30
|
+
* Returns the plan with the module's address (predicted by the factory) and the mandate JSON to
|
|
31
|
+
* keep; `plan.params.mandateHash` is the on-chain commitment that ratings and quotes use. Then
|
|
32
|
+
* use `new BondedVault({ vault: plan.module, ... })` from the agent's side.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installMandateModule(client: ContractReader, input: InstallMandateModuleInput): Promise<ModuleInstallPlan & {
|
|
35
|
+
mandate: EvmMandate;
|
|
36
|
+
}>;
|
|
37
|
+
//# sourceMappingURL=module.d.ts.map
|
package/dist/module.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { prepareModuleInstall, } from "@bondedhq/shared";
|
|
2
|
+
import { buildMandate } from "./mandate.js";
|
|
3
|
+
/**
|
|
4
|
+
* Approvals a module can't see: before installing (and while installed), list the account's
|
|
5
|
+
* allowances to the mandate's targets for tokens the mandate doesn't cover, and revoke them.
|
|
6
|
+
* See `checkUnprotectedAllowances` in @bondedhq/shared.
|
|
7
|
+
*/
|
|
8
|
+
export { checkUnprotectedAllowances, deploymentTokens, unprotectedAllowancesWarning, } from "@bondedhq/shared";
|
|
9
|
+
/**
|
|
10
|
+
* Everything a smart account needs to put an agent's mandate on itself in one transaction:
|
|
11
|
+
* create its MandateModule through the factory, and install it as executor (type 2) and hook
|
|
12
|
+
* (type 4). Nothing is sent: the account's owner sends `plan.calls` as a Safe MultiSend from
|
|
13
|
+
* the Safe, or `plan.execute.data` to an ERC-7579 account (e.g. as a user operation).
|
|
14
|
+
*
|
|
15
|
+
* Returns the plan with the module's address (predicted by the factory) and the mandate JSON to
|
|
16
|
+
* keep; `plan.params.mandateHash` is the on-chain commitment that ratings and quotes use. Then
|
|
17
|
+
* use `new BondedVault({ vault: plan.module, ... })` from the agent's side.
|
|
18
|
+
*/
|
|
19
|
+
export async function installMandateModule(client, input) {
|
|
20
|
+
const { mandate, params } = buildMandate(input.rules, { for: "module" });
|
|
21
|
+
const plan = await prepareModuleInstall(client, {
|
|
22
|
+
moduleFactory: input.moduleFactory,
|
|
23
|
+
account: input.account,
|
|
24
|
+
params,
|
|
25
|
+
agent: input.agent,
|
|
26
|
+
guardian: input.guardian,
|
|
27
|
+
agentId: BigInt(input.rules.agentId),
|
|
28
|
+
accountType: input.accountType,
|
|
29
|
+
});
|
|
30
|
+
return { ...plan, mandate };
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=module.js.map
|
package/dist/revert.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Address } from "viem";
|
|
2
|
+
/**
|
|
3
|
+
* Turns a reverted `execute` into the SDK's typed errors: the vault's or module's own errors
|
|
4
|
+
* (wrong key, frozen, not installed, calling itself) by name, anything else as the target call
|
|
5
|
+
* failing. Other errors (network, nonce) are returned unchanged.
|
|
6
|
+
*/
|
|
7
|
+
export declare function vaultRevertError(error: unknown, context: {
|
|
8
|
+
vault: Address;
|
|
9
|
+
target: Address;
|
|
10
|
+
signer?: Address | undefined;
|
|
11
|
+
}): unknown;
|
|
12
|
+
//# sourceMappingURL=revert.d.ts.map
|
package/dist/revert.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mandateModuleAbi, mandateVaultAbi } from "@bondedhq/shared";
|
|
2
|
+
import { decodeErrorResult, isHex, parseAbi } from "viem";
|
|
3
|
+
import { BondedError, CallRevertedError, NotAgentError, VaultFrozenError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Every error `execute` can revert with: the vault's and the module's own, and the error an
|
|
6
|
+
* ERC-7579 account (Safe7579, the reference account) raises when the module's call fails.
|
|
7
|
+
*/
|
|
8
|
+
const REVERT_ABI = [
|
|
9
|
+
...mandateVaultAbi.filter((item) => item.type === "error"),
|
|
10
|
+
...mandateModuleAbi.filter((item) => item.type === "error"),
|
|
11
|
+
...parseAbi(["error ExecutionFailed()"]),
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Turns a reverted `execute` into the SDK's typed errors: the vault's or module's own errors
|
|
15
|
+
* (wrong key, frozen, not installed, calling itself) by name, anything else as the target call
|
|
16
|
+
* failing. Other errors (network, nonce) are returned unchanged.
|
|
17
|
+
*/
|
|
18
|
+
export function vaultRevertError(error, context) {
|
|
19
|
+
const revert = findRevert(error);
|
|
20
|
+
if (!revert)
|
|
21
|
+
return error;
|
|
22
|
+
switch (revert.errorName) {
|
|
23
|
+
case "NotAgent":
|
|
24
|
+
return new NotAgentError(context.vault, context.signer, { cause: error });
|
|
25
|
+
case "AgentFrozen":
|
|
26
|
+
return new VaultFrozenError(context.vault, undefined, undefined, { cause: error });
|
|
27
|
+
case "CannotCallVault":
|
|
28
|
+
return new BondedError("INVALID_ACTION", "the vault can't call itself", { cause: error });
|
|
29
|
+
case "CannotCallAccount":
|
|
30
|
+
case "CannotCallModule":
|
|
31
|
+
return new BondedError("INVALID_ACTION", "the module can't call its own account or itself (that could change the account's setup)", { cause: error });
|
|
32
|
+
case "NotInstalled":
|
|
33
|
+
return new BondedError("MODULE_NOT_INSTALLED", `module ${context.vault} isn't installed on its account; the account's owner installs it as an executor (and hook)`, { cause: error });
|
|
34
|
+
case "ExecutionFailed":
|
|
35
|
+
return new CallRevertedError(`the account's call to ${context.target} failed (the account reports ExecutionFailed without the reason); nothing changed`, revert.raw, { cause: error });
|
|
36
|
+
}
|
|
37
|
+
const why = revert.reason ?? revert.errorName ?? revert.signature;
|
|
38
|
+
return new CallRevertedError(`the call to ${context.target} reverted${why ? `: ${why}` : ""}; nothing changed`, revert.raw, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
/** Errors are matched by name, not instanceof: clients may come from another copy of viem. */
|
|
41
|
+
function findRevert(error) {
|
|
42
|
+
const chain = [];
|
|
43
|
+
for (let e = error, depth = 0; e instanceof Error && depth < 20; e = e.cause, depth++)
|
|
44
|
+
chain.push(e);
|
|
45
|
+
const reverted = chain.find((e) => e.name === "ContractFunctionRevertedError");
|
|
46
|
+
// The revert data: on viem's decoded error, or on a lower error when it was decoded without
|
|
47
|
+
// the vault's ABI (e.g. by another library estimating gas).
|
|
48
|
+
const raw = reverted?.raw ??
|
|
49
|
+
chain
|
|
50
|
+
.map((e) => e.data)
|
|
51
|
+
.find((d) => typeof d === "string" && isHex(d) && d.length >= 10);
|
|
52
|
+
if (raw && raw.length >= 10) {
|
|
53
|
+
try {
|
|
54
|
+
// viem also decodes the built-in Error(string) and Panic(uint256).
|
|
55
|
+
const decoded = decodeErrorResult({ abi: REVERT_ABI, data: raw });
|
|
56
|
+
const reason = decoded.errorName === "Error" ? String(decoded.args?.[0]) : undefined;
|
|
57
|
+
return { errorName: decoded.errorName, reason, raw };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { reason: reverted?.reason, raw, signature: raw.slice(0, 10) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (reverted)
|
|
64
|
+
return {
|
|
65
|
+
errorName: reverted.data?.errorName,
|
|
66
|
+
reason: reverted.reason,
|
|
67
|
+
signature: reverted.signature,
|
|
68
|
+
};
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=revert.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type SentinelInput } from "@bondedhq/shared";
|
|
2
|
+
import type { Address, Hex, SignableMessage } from "viem";
|
|
3
|
+
import { type SentinelVerdict } from "./errors.js";
|
|
4
|
+
export type { SentinelInput, SentinelVerdict };
|
|
5
|
+
/** Signs EIP-191 messages with the vault's agent key. A viem `LocalAccount` is one. */
|
|
6
|
+
export interface MessageSigner {
|
|
7
|
+
signMessage(args: {
|
|
8
|
+
message: SignableMessage;
|
|
9
|
+
}): Promise<Hex>;
|
|
10
|
+
}
|
|
11
|
+
export interface SentinelCheckRequest {
|
|
12
|
+
/** The Bonded API, e.g. https://api.bonded.example (no trailing path). */
|
|
13
|
+
apiUrl: string;
|
|
14
|
+
/** The chain the vault is on; part of the signed message. */
|
|
15
|
+
chainId: number;
|
|
16
|
+
/** The agent's id in the Bonded registry. */
|
|
17
|
+
agentId: bigint | number | string;
|
|
18
|
+
vault: Address;
|
|
19
|
+
/** The mandate in plain words (`describeMandate` writes one). At most 4,000 characters. */
|
|
20
|
+
mandate: string;
|
|
21
|
+
/** What the agent wants to do: a tool name and its arguments as strings. */
|
|
22
|
+
proposedAction: {
|
|
23
|
+
tool: string;
|
|
24
|
+
args: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
/** What the agent read from outside before deciding (emails, posts, tool output). At most 20. */
|
|
27
|
+
untrustedInputs?: readonly SentinelInput[];
|
|
28
|
+
/** The vault's agent key: the API only answers checks signed by it. */
|
|
29
|
+
account: MessageSigner;
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
/** Unix seconds to sign; defaults to now. The API accepts a check for 120 seconds. */
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Asks Bonded Sentinel whether a proposed action looks driven by what the agent read rather
|
|
37
|
+
* than by its mandate. Signs the request with the agent key, as the API requires.
|
|
38
|
+
*
|
|
39
|
+
* Advisory only: the vault's breach log, not Sentinel, decides claims and ratings. Throws
|
|
40
|
+
* `SentinelError` when the API refuses or can't answer (it returns 503 while Sentinel is down).
|
|
41
|
+
*/
|
|
42
|
+
export declare function checkWithSentinel(request: SentinelCheckRequest): Promise<SentinelVerdict>;
|
|
43
|
+
//# sourceMappingURL=sentinel.d.ts.map
|
package/dist/sentinel.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SENTINEL_INPUT_CHARS, sentinelCheckDigest, sentinelCheckMessage, } from "@bondedhq/shared";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BondedError, SentinelError } from "./errors.js";
|
|
4
|
+
const verdictSchema = z.object({
|
|
5
|
+
blocked: z.boolean(),
|
|
6
|
+
manipulated: z.number(),
|
|
7
|
+
injection: z.number(),
|
|
8
|
+
blockThreshold: z.number(),
|
|
9
|
+
model: z.string(),
|
|
10
|
+
latencyMs: z.number(),
|
|
11
|
+
advice: z.string(),
|
|
12
|
+
});
|
|
13
|
+
/** The API's limits on a check (apps/api/src/sentinel.ts), checked here for clearer errors. */
|
|
14
|
+
const LIMITS = { mandate: 4_000, tool: 64, args: 20, argKey: 64, argValue: 2_000, inputs: 20 };
|
|
15
|
+
/**
|
|
16
|
+
* Asks Bonded Sentinel whether a proposed action looks driven by what the agent read rather
|
|
17
|
+
* than by its mandate. Signs the request with the agent key, as the API requires.
|
|
18
|
+
*
|
|
19
|
+
* Advisory only: the vault's breach log, not Sentinel, decides claims and ratings. Throws
|
|
20
|
+
* `SentinelError` when the API refuses or can't answer (it returns 503 while Sentinel is down).
|
|
21
|
+
*/
|
|
22
|
+
export async function checkWithSentinel(request) {
|
|
23
|
+
const body = {
|
|
24
|
+
mandate: request.mandate,
|
|
25
|
+
proposedAction: request.proposedAction,
|
|
26
|
+
// Sentinel only sees the first SENTINEL_INPUT_CHARS of each input; the API rejects more.
|
|
27
|
+
untrustedInputs: (request.untrustedInputs ?? []).map((input) => ({
|
|
28
|
+
tool: input.tool,
|
|
29
|
+
output: input.output.slice(0, SENTINEL_INPUT_CHARS),
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
checkLimits(body);
|
|
33
|
+
const agentId = request.agentId.toString();
|
|
34
|
+
const timestamp = request.timestamp ?? Math.floor(Date.now() / 1000);
|
|
35
|
+
const signature = await request.account.signMessage({
|
|
36
|
+
message: sentinelCheckMessage({
|
|
37
|
+
chainId: request.chainId,
|
|
38
|
+
agentId,
|
|
39
|
+
vault: request.vault,
|
|
40
|
+
digest: sentinelCheckDigest(body),
|
|
41
|
+
timestamp,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
const url = `${request.apiUrl.replace(/\/+$/, "")}/sentinel/check`;
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await (request.fetch ?? fetch)(url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "content-type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ agentId, vault: request.vault, timestamp, signature, ...body }),
|
|
51
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new SentinelError(`couldn't reach ${url}: ${error.message}`, undefined, {
|
|
56
|
+
cause: error,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const json = await response.json().catch(() => undefined);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const message = json && typeof json === "object" && "error" in json && typeof json.error === "string"
|
|
62
|
+
? json.error
|
|
63
|
+
: response.statusText || "no reason given";
|
|
64
|
+
throw new SentinelError(`Sentinel check refused (${response.status}): ${message}`, response.status);
|
|
65
|
+
}
|
|
66
|
+
const verdict = verdictSchema.safeParse(json);
|
|
67
|
+
if (!verdict.success)
|
|
68
|
+
throw new SentinelError(`unexpected answer from ${url}`, response.status, {
|
|
69
|
+
cause: verdict.error,
|
|
70
|
+
});
|
|
71
|
+
return verdict.data;
|
|
72
|
+
}
|
|
73
|
+
function checkLimits(body) {
|
|
74
|
+
const fail = (what) => {
|
|
75
|
+
throw new BondedError("SENTINEL_REQUEST_TOO_LARGE", `Sentinel check: ${what}`);
|
|
76
|
+
};
|
|
77
|
+
if (body.mandate.length === 0 || body.mandate.length > LIMITS.mandate)
|
|
78
|
+
fail(`the mandate must be 1 to ${LIMITS.mandate} characters`);
|
|
79
|
+
const { tool, args } = body.proposedAction;
|
|
80
|
+
if (tool.length === 0 || tool.length > LIMITS.tool)
|
|
81
|
+
fail(`the tool name must be 1 to ${LIMITS.tool} characters`);
|
|
82
|
+
const entries = Object.entries(args);
|
|
83
|
+
if (entries.length > LIMITS.args)
|
|
84
|
+
fail(`at most ${LIMITS.args} arguments`);
|
|
85
|
+
for (const [key, value] of entries)
|
|
86
|
+
if (key.length > LIMITS.argKey || value.length > LIMITS.argValue)
|
|
87
|
+
fail(`argument ${key.slice(0, 20)} is too long`);
|
|
88
|
+
if (body.untrustedInputs.length > LIMITS.inputs)
|
|
89
|
+
fail(`at most ${LIMITS.inputs} untrusted inputs; send the ones read for this action`);
|
|
90
|
+
for (const input of body.untrustedInputs)
|
|
91
|
+
if (input.tool.length === 0 || input.tool.length > LIMITS.tool)
|
|
92
|
+
fail(`each input's tool name must be 1 to ${LIMITS.tool} characters`);
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=sentinel.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const SIGNATURE_HEADER = "bonded-signature";
|
|
2
|
+
export declare const TIMESTAMP_HEADER = "bonded-timestamp";
|
|
3
|
+
/** How far a request's timestamp may be from the agent's clock, either way. */
|
|
4
|
+
export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
5
|
+
/**
|
|
6
|
+
* Signs a body as Bonded does: the two headers to send with it. For testing your endpoint
|
|
7
|
+
* locally; Bonded signs real calls itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare function signBondedRequest(secret: string, body: string, timestamp?: number): Promise<{
|
|
10
|
+
"bonded-timestamp": string;
|
|
11
|
+
"bonded-signature": string;
|
|
12
|
+
}>;
|
|
13
|
+
export type SignatureCheck = {
|
|
14
|
+
ok: true;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Checks a request's signature only: the timestamp is within `toleranceSeconds` of now and one of
|
|
22
|
+
* the header's `v1=` signatures is the body's HMAC under `secret` (or one of several secrets).
|
|
23
|
+
* It doesn't look at the body or check for replays; `createBondedHandler` does both.
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifyBondedSignature(input: {
|
|
26
|
+
secret: string | string[];
|
|
27
|
+
body: string;
|
|
28
|
+
timestamp: string | null | undefined;
|
|
29
|
+
signature: string | null | undefined;
|
|
30
|
+
/** Unix seconds. Default: now. */
|
|
31
|
+
now?: number;
|
|
32
|
+
toleranceSeconds?: number;
|
|
33
|
+
}): Promise<SignatureCheck>;
|
|
34
|
+
//# sourceMappingURL=signature.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent URL request signatures: how an agent knows a request really comes from Bonded.
|
|
3
|
+
*
|
|
4
|
+
* Bonded signs every call to an Agent URL with the agent's secret:
|
|
5
|
+
* - `bonded-timestamp`: unix seconds;
|
|
6
|
+
* - `bonded-signature`: `v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` (up to four,
|
|
7
|
+
* comma-separated, while a secret is rotated).
|
|
8
|
+
*
|
|
9
|
+
* The scheme lives in @bondedhq/shared (`@bondedhq/shared/agent-url`, which the gateway signs with);
|
|
10
|
+
* these are the SDK's names for it. Web Crypto only, so it runs in Node, Bun, Deno, Cloudflare
|
|
11
|
+
* Workers and edge runtimes, and the comparison is Web Crypto's constant-time HMAC verify.
|
|
12
|
+
*/
|
|
13
|
+
import { AGENT_URL_SIGNATURE_HEADER, AGENT_URL_TIMESTAMP_HEADER, AGENT_URL_TOLERANCE_SECONDS, signAgentUrlRequest, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
|
|
14
|
+
export const SIGNATURE_HEADER = AGENT_URL_SIGNATURE_HEADER;
|
|
15
|
+
export const TIMESTAMP_HEADER = AGENT_URL_TIMESTAMP_HEADER;
|
|
16
|
+
/** How far a request's timestamp may be from the agent's clock, either way. */
|
|
17
|
+
export const SIGNATURE_TOLERANCE_SECONDS = AGENT_URL_TOLERANCE_SECONDS;
|
|
18
|
+
/**
|
|
19
|
+
* Signs a body as Bonded does: the two headers to send with it. For testing your endpoint
|
|
20
|
+
* locally; Bonded signs real calls itself.
|
|
21
|
+
*/
|
|
22
|
+
export async function signBondedRequest(secret, body, timestamp = Math.floor(Date.now() / 1000)) {
|
|
23
|
+
const headers = await signAgentUrlRequest(secret, body, timestamp * 1000);
|
|
24
|
+
return {
|
|
25
|
+
"bonded-timestamp": headers[TIMESTAMP_HEADER],
|
|
26
|
+
"bonded-signature": headers[SIGNATURE_HEADER],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Checks a request's signature only: the timestamp is within `toleranceSeconds` of now and one of
|
|
31
|
+
* the header's `v1=` signatures is the body's HMAC under `secret` (or one of several secrets).
|
|
32
|
+
* It doesn't look at the body or check for replays; `createBondedHandler` does both.
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyBondedSignature(input) {
|
|
35
|
+
const result = await verifyAgentUrlRequest({
|
|
36
|
+
secret: input.secret,
|
|
37
|
+
body: input.body,
|
|
38
|
+
headers: {
|
|
39
|
+
[TIMESTAMP_HEADER]: input.timestamp ?? undefined,
|
|
40
|
+
[SIGNATURE_HEADER]: input.signature ?? undefined,
|
|
41
|
+
},
|
|
42
|
+
...(input.now !== undefined ? { nowMs: input.now * 1000 } : {}),
|
|
43
|
+
...(input.toleranceSeconds !== undefined ? { toleranceSeconds: input.toleranceSeconds } : {}),
|
|
44
|
+
});
|
|
45
|
+
if (result.ok)
|
|
46
|
+
return { ok: true, timestamp: result.timestamp };
|
|
47
|
+
// 400 means the signature matched but the body isn't one of Bonded's messages.
|
|
48
|
+
if (result.status === 400)
|
|
49
|
+
return { ok: true, timestamp: Number(input.timestamp) };
|
|
50
|
+
return { ok: false, reason: result.reason };
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=signature.js.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type BreachEvent, type VaultAccount } from "@bondedhq/solana";
|
|
2
|
+
import { type AddressLookupTableAccount, type Connection, PublicKey, type Transaction, TransactionInstruction, VersionedTransaction } from "@solana/web3.js";
|
|
3
|
+
import type { Action, SolanaAgentKit } from "solana-agent-kit";
|
|
4
|
+
import { BondedError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Bonded's Solana vault (`bonded_vault`) for Solana Agent Kit. What the program checks (its
|
|
7
|
+
* `classify`), and so what this plugin can do:
|
|
8
|
+
*
|
|
9
|
+
* - The called program must be on the vault's allowlist (T1 target).
|
|
10
|
+
* - SPL `Transfer`/`TransferChecked` must go to a token account the vault owns (T1 transfer).
|
|
11
|
+
* There is no destination allowlist on Solana: any transfer out of the vault is a breach.
|
|
12
|
+
* - `Approve`, `ApproveChecked`, `SetAuthority` and `CloseAccount` are never allowed.
|
|
13
|
+
* - The net base-asset outflow of one call must not exceed the vault's limit (T2).
|
|
14
|
+
* - No T3 (price) check yet, and no 24h limit.
|
|
15
|
+
*
|
|
16
|
+
* One instruction per `execute`, called by CPI with the vault as signer, so CPI depth and the
|
|
17
|
+
* compute budget limit what can run (Jupiter multi-hop routes may not fit).
|
|
18
|
+
*/
|
|
19
|
+
/** A breach the vault recorded (or would record), from its `BreachRecorded` event. */
|
|
20
|
+
export type SolanaVaultBreach = BreachEvent;
|
|
21
|
+
/** What a vault action did. Monitor mode lets breaches through: check `breaches`. */
|
|
22
|
+
export interface SolanaActionResult {
|
|
23
|
+
signature: string;
|
|
24
|
+
breaches: SolanaVaultBreach[];
|
|
25
|
+
}
|
|
26
|
+
/** What the vault would do with an instruction, from simulating `execute`. */
|
|
27
|
+
export interface SolanaDryRunResult {
|
|
28
|
+
/** The vault would run it and record no breach. */
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** Enforce mode would stop it. */
|
|
31
|
+
blocked: boolean;
|
|
32
|
+
breach?: SolanaVaultBreach;
|
|
33
|
+
breaches: SolanaVaultBreach[];
|
|
34
|
+
}
|
|
35
|
+
/** An error from the Solana vault, with the breach when the vault recorded one. */
|
|
36
|
+
export declare class SolanaVaultError extends BondedError {
|
|
37
|
+
readonly breach?: SolanaVaultBreach | undefined;
|
|
38
|
+
readonly signature?: string | undefined;
|
|
39
|
+
readonly name = "SolanaVaultError";
|
|
40
|
+
constructor(code: string, message: string, breach?: SolanaVaultBreach | undefined, signature?: string | undefined, options?: {
|
|
41
|
+
cause?: unknown;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Signs transactions as the vault's agent key. Solana Agent Kit's wallets are this. */
|
|
45
|
+
export interface SolanaSigner {
|
|
46
|
+
publicKey: PublicKey;
|
|
47
|
+
signTransaction<T extends Transaction | VersionedTransaction>(transaction: T): Promise<T>;
|
|
48
|
+
}
|
|
49
|
+
/** Builds a swap where the vault owns the tokens, e.g. from Jupiter's swap-instructions API. */
|
|
50
|
+
export type SolanaSwapBuilder = (params: {
|
|
51
|
+
vault: PublicKey;
|
|
52
|
+
inputMint: PublicKey;
|
|
53
|
+
outputMint: PublicKey;
|
|
54
|
+
/** In `inputMint` base units. */
|
|
55
|
+
amount: bigint;
|
|
56
|
+
slippageBps: number;
|
|
57
|
+
}) => Promise<{
|
|
58
|
+
/** The swap, with the vault as the token authority. It runs inside the vault's `execute`. */
|
|
59
|
+
instruction: TransactionInstruction;
|
|
60
|
+
/** Instructions the agent key runs first, outside the vault (e.g. creating token accounts). */
|
|
61
|
+
setup?: TransactionInstruction[];
|
|
62
|
+
lookupTables?: AddressLookupTableAccount[];
|
|
63
|
+
}>;
|
|
64
|
+
/** The associated token account of `owner` for `mint` (the vault, a PDA, included). */
|
|
65
|
+
export declare function associatedTokenAddress(owner: PublicKey, mint: PublicKey): PublicKey;
|
|
66
|
+
/**
|
|
67
|
+
* A Bonded vault on Solana, from the agent's side. Every action is the vault program's
|
|
68
|
+
* `execute`, signed by the agent key.
|
|
69
|
+
*/
|
|
70
|
+
export declare class SolanaBondedVault {
|
|
71
|
+
readonly connection: Connection;
|
|
72
|
+
readonly agent: SolanaSigner;
|
|
73
|
+
readonly address: PublicKey;
|
|
74
|
+
constructor(connection: Connection, vault: PublicKey | string, agent: SolanaSigner);
|
|
75
|
+
status(): Promise<VaultAccount>;
|
|
76
|
+
/**
|
|
77
|
+
* Send `amount` (base units) of `mint` from the vault to `to`'s associated token account,
|
|
78
|
+
* creating it (paid by the agent key) if it doesn't exist. Only a transfer to the vault's own
|
|
79
|
+
* account keeps to the mandate on Solana.
|
|
80
|
+
*/
|
|
81
|
+
transfer(params: {
|
|
82
|
+
mint: PublicKey;
|
|
83
|
+
to: PublicKey;
|
|
84
|
+
amount: bigint;
|
|
85
|
+
}): Promise<SolanaActionResult>;
|
|
86
|
+
/** Runs `inner` through the vault's `execute`, after any `setup` the agent key runs itself. */
|
|
87
|
+
execute(inner: TransactionInstruction, setup?: TransactionInstruction[], lookupTables?: AddressLookupTableAccount[]): Promise<SolanaActionResult>;
|
|
88
|
+
/** What the vault would do with `inner`: simulates `execute` against the cluster's state. */
|
|
89
|
+
dryRun(inner: TransactionInstruction, setup?: TransactionInstruction[], lookupTables?: AddressLookupTableAccount[]): Promise<SolanaDryRunResult>;
|
|
90
|
+
/**
|
|
91
|
+
* The SPL `TransferChecked` the vault would sign for `transfer`, and the account creation the
|
|
92
|
+
* agent key runs first if needed. Pass both to `dryRun` to preview a transfer.
|
|
93
|
+
*/
|
|
94
|
+
transferPlan(params: {
|
|
95
|
+
mint: PublicKey;
|
|
96
|
+
to: PublicKey;
|
|
97
|
+
amount: bigint;
|
|
98
|
+
}): Promise<{
|
|
99
|
+
instruction: TransactionInstruction;
|
|
100
|
+
setup: TransactionInstruction[];
|
|
101
|
+
}>;
|
|
102
|
+
private build;
|
|
103
|
+
/** Names the vault program's error when the failure is one, e.g. a frozen vault. */
|
|
104
|
+
private programError;
|
|
105
|
+
}
|
|
106
|
+
export interface BondedSolanaPluginOptions {
|
|
107
|
+
/** The vault account (the PDA from `address.vault(guardian, agentId)`). */
|
|
108
|
+
vault: PublicKey | string;
|
|
109
|
+
/** Simulate each action first and refuse a predicted breach. Default false: the vault judges. */
|
|
110
|
+
guard?: boolean;
|
|
111
|
+
/** Enables `BONDED_VAULT_SWAP`; without it the plugin has no swap. */
|
|
112
|
+
swap?: SolanaSwapBuilder;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A Solana Agent Kit plugin whose transfer and swap go through a Bonded vault. The kit's wallet
|
|
116
|
+
* must be the vault's agent key. Methods (`agent.methods.*`) take base units; actions take whole
|
|
117
|
+
* units, as an LLM writes them.
|
|
118
|
+
*/
|
|
119
|
+
export declare function bondedVaultPlugin(options: BondedSolanaPluginOptions): {
|
|
120
|
+
name: string;
|
|
121
|
+
methods: {
|
|
122
|
+
/** The vault's account: mode, frozen, allowed programs, limit and breach log. */
|
|
123
|
+
bondedVaultStatus: (agent: SolanaAgentKit) => Promise<VaultAccount>;
|
|
124
|
+
bondedVaultTransfer: (agent: SolanaAgentKit, params: {
|
|
125
|
+
mint: PublicKey;
|
|
126
|
+
to: PublicKey;
|
|
127
|
+
amount: bigint;
|
|
128
|
+
}) => Promise<SolanaActionResult>;
|
|
129
|
+
bondedVaultSwap: (agent: SolanaAgentKit, params: {
|
|
130
|
+
inputMint: PublicKey;
|
|
131
|
+
outputMint: PublicKey;
|
|
132
|
+
amount: bigint;
|
|
133
|
+
slippageBps?: number;
|
|
134
|
+
}) => Promise<SolanaActionResult>;
|
|
135
|
+
/** Any instruction with the vault as signer, through `execute`. */
|
|
136
|
+
bondedVaultExecute: (agent: SolanaAgentKit, instruction: TransactionInstruction) => Promise<SolanaActionResult>;
|
|
137
|
+
};
|
|
138
|
+
actions: Action[];
|
|
139
|
+
initialize: () => void;
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=solana-agent-kit.d.ts.map
|