@keeperhub/wallet 0.1.2 → 0.1.4
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/cli.cjs +20 -187
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +20 -187
- package/dist/cli.js.map +1 -1
- package/dist/hook-entrypoint.cjs +0 -185
- package/dist/hook-entrypoint.cjs.map +1 -1
- package/dist/hook-entrypoint.js +0 -185
- package/dist/hook-entrypoint.js.map +1 -1
- package/dist/index.cjs +128 -232
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -67
- package/dist/index.d.ts +63 -67
- package/dist/index.js +128 -232
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skill/keeperhub-wallet.skill.md +26 -8
package/dist/hook-entrypoint.cjs
CHANGED
|
@@ -24,124 +24,6 @@ __export(hook_entrypoint_exports, {
|
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(hook_entrypoint_exports);
|
|
26
26
|
|
|
27
|
-
// src/hmac.ts
|
|
28
|
-
var import_node_crypto = require("crypto");
|
|
29
|
-
function computeSignature(secret, method, path, subOrgId, body, timestamp) {
|
|
30
|
-
const bodyDigest = (0, import_node_crypto.createHash)("sha256").update(body).digest("hex");
|
|
31
|
-
const signingString = `${method}
|
|
32
|
-
${path}
|
|
33
|
-
${subOrgId}
|
|
34
|
-
${bodyDigest}
|
|
35
|
-
${timestamp}`;
|
|
36
|
-
return (0, import_node_crypto.createHmac)("sha256", secret).update(signingString).digest("hex");
|
|
37
|
-
}
|
|
38
|
-
function buildHmacHeaders(secret, method, path, subOrgId, body) {
|
|
39
|
-
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
40
|
-
const signature = computeSignature(
|
|
41
|
-
secret,
|
|
42
|
-
method,
|
|
43
|
-
path,
|
|
44
|
-
subOrgId,
|
|
45
|
-
body,
|
|
46
|
-
timestamp
|
|
47
|
-
);
|
|
48
|
-
return {
|
|
49
|
-
"X-KH-Sub-Org": subOrgId,
|
|
50
|
-
"X-KH-Timestamp": timestamp,
|
|
51
|
-
"X-KH-Signature": signature
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// src/types.ts
|
|
56
|
-
var KeeperHubError = class extends Error {
|
|
57
|
-
code;
|
|
58
|
-
constructor(code, message) {
|
|
59
|
-
super(message);
|
|
60
|
-
this.name = "KeeperHubError";
|
|
61
|
-
this.code = code;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
var WalletConfigMissingError = class extends Error {
|
|
65
|
-
constructor() {
|
|
66
|
-
super(
|
|
67
|
-
"Wallet config not found at ~/.keeperhub/wallet.json. Run `npx @keeperhub/wallet add` to provision."
|
|
68
|
-
);
|
|
69
|
-
this.name = "WalletConfigMissingError";
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// src/client.ts
|
|
74
|
-
var TRAILING_SLASH = /\/$/;
|
|
75
|
-
function defaultCodeForStatus(status) {
|
|
76
|
-
if (status === 401) {
|
|
77
|
-
return "HMAC_INVALID";
|
|
78
|
-
}
|
|
79
|
-
if (status === 403) {
|
|
80
|
-
return "POLICY_BLOCKED";
|
|
81
|
-
}
|
|
82
|
-
if (status === 404) {
|
|
83
|
-
return "NOT_FOUND";
|
|
84
|
-
}
|
|
85
|
-
if (status === 502) {
|
|
86
|
-
return "TURNKEY_UPSTREAM";
|
|
87
|
-
}
|
|
88
|
-
return `HTTP_${status}`;
|
|
89
|
-
}
|
|
90
|
-
var KeeperHubClient = class {
|
|
91
|
-
baseUrl;
|
|
92
|
-
fetchImpl;
|
|
93
|
-
wallet;
|
|
94
|
-
constructor(wallet, opts = {}) {
|
|
95
|
-
this.wallet = wallet;
|
|
96
|
-
const envBase = process.env.KEEPERHUB_API_URL;
|
|
97
|
-
this.baseUrl = (opts.baseUrl ?? envBase ?? "https://app.keeperhub.com").replace(TRAILING_SLASH, "");
|
|
98
|
-
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* HMAC-signed POST/GET to any /api/agentic-wallet/* route except
|
|
102
|
-
* /provision. Path MUST start with a leading slash. Body is
|
|
103
|
-
* JSON.stringify'd (or the empty string for GET).
|
|
104
|
-
*
|
|
105
|
-
* Error mapping: non-2xx/non-202 surface as `KeeperHubError(code,
|
|
106
|
-
* message)` where `code` is the server-supplied field or the default
|
|
107
|
-
* taxonomy (`HMAC_INVALID`, `POLICY_BLOCKED`, `NOT_FOUND`,
|
|
108
|
-
* `TURNKEY_UPSTREAM`, `HTTP_<status>`). 202 ask-tier surfaces as an
|
|
109
|
-
* AskTierResponse envelope.
|
|
110
|
-
*/
|
|
111
|
-
async request(method, path, body) {
|
|
112
|
-
const bodyStr = body === void 0 ? "" : JSON.stringify(body);
|
|
113
|
-
const hmacHeaders = buildHmacHeaders(
|
|
114
|
-
this.wallet.hmacSecret,
|
|
115
|
-
method,
|
|
116
|
-
path,
|
|
117
|
-
this.wallet.subOrgId,
|
|
118
|
-
bodyStr
|
|
119
|
-
);
|
|
120
|
-
const headers = method === "POST" ? { ...hmacHeaders, "content-type": "application/json" } : { ...hmacHeaders };
|
|
121
|
-
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
122
|
-
method,
|
|
123
|
-
headers,
|
|
124
|
-
body: method === "POST" ? bodyStr : void 0
|
|
125
|
-
});
|
|
126
|
-
if (response.status === 202) {
|
|
127
|
-
const data = await response.json();
|
|
128
|
-
return { _status: 202, approvalRequestId: data.approvalRequestId };
|
|
129
|
-
}
|
|
130
|
-
if (!response.ok) {
|
|
131
|
-
let code = "UNKNOWN";
|
|
132
|
-
let message = `HTTP ${response.status}`;
|
|
133
|
-
try {
|
|
134
|
-
const data = await response.json();
|
|
135
|
-
code = data.code ?? defaultCodeForStatus(response.status);
|
|
136
|
-
message = data.error ?? message;
|
|
137
|
-
} catch {
|
|
138
|
-
}
|
|
139
|
-
throw new KeeperHubError(code, message);
|
|
140
|
-
}
|
|
141
|
-
return await response.json();
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
|
|
145
27
|
// src/safety-config.ts
|
|
146
28
|
var import_promises = require("fs/promises");
|
|
147
29
|
var import_node_os = require("os");
|
|
@@ -218,31 +100,7 @@ function validateAndMerge(partial) {
|
|
|
218
100
|
return merged;
|
|
219
101
|
}
|
|
220
102
|
|
|
221
|
-
// src/storage.ts
|
|
222
|
-
var import_promises2 = require("fs/promises");
|
|
223
|
-
var import_node_os2 = require("os");
|
|
224
|
-
var import_node_path2 = require("path");
|
|
225
|
-
async function readWalletConfig() {
|
|
226
|
-
const walletPath = (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".keeperhub", "wallet.json");
|
|
227
|
-
let raw;
|
|
228
|
-
try {
|
|
229
|
-
raw = await (0, import_promises2.readFile)(walletPath, "utf-8");
|
|
230
|
-
} catch (err) {
|
|
231
|
-
if (err.code === "ENOENT") {
|
|
232
|
-
throw new WalletConfigMissingError();
|
|
233
|
-
}
|
|
234
|
-
throw err;
|
|
235
|
-
}
|
|
236
|
-
const parsed = JSON.parse(raw);
|
|
237
|
-
if (!(parsed.subOrgId && parsed.walletAddress && parsed.hmacSecret)) {
|
|
238
|
-
throw new Error(`Malformed wallet.json at ${walletPath}`);
|
|
239
|
-
}
|
|
240
|
-
return parsed;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
103
|
// src/hook.ts
|
|
244
|
-
var DEFAULT_POLL = { intervalMs: 2e3, maxAttempts: 150 };
|
|
245
|
-
var APPROVAL_URL_BASE = "https://app.keeperhub.com/approve/";
|
|
246
104
|
var USDC_DECIMALS = 1e6;
|
|
247
105
|
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
248
106
|
var MICRO_USDC_RE = /^\d+$/;
|
|
@@ -293,16 +151,6 @@ function usdToMicro(usd) {
|
|
|
293
151
|
async function createPreToolUseHook(options = {}) {
|
|
294
152
|
const toolMatcher = options.toolNameMatcher ?? defaultToolMatcher;
|
|
295
153
|
const configLoader = options.configLoader ?? loadSafetyConfig;
|
|
296
|
-
const walletLoader = options.walletLoader ?? readWalletConfig;
|
|
297
|
-
const clientFactory = options.clientFactory ?? ((w) => new KeeperHubClient(w));
|
|
298
|
-
const onAskOpen = options.onAskOpen ?? ((url) => {
|
|
299
|
-
process.stderr.write(
|
|
300
|
-
`
|
|
301
|
-
[keeperhub-wallet] Approval required. Visit: ${url}
|
|
302
|
-
`
|
|
303
|
-
);
|
|
304
|
-
});
|
|
305
|
-
const poll = options.poll ?? DEFAULT_POLL;
|
|
306
154
|
const safety = await configLoader();
|
|
307
155
|
return async (raw) => {
|
|
308
156
|
const hookInput = raw ?? {};
|
|
@@ -318,43 +166,10 @@ async function createPreToolUseHook(options = {}) {
|
|
|
318
166
|
return { decision: "deny", reason: "AMOUNT_UNDETERMINED" };
|
|
319
167
|
}
|
|
320
168
|
const blockMicro = usdToMicro(safety.block_threshold_usd);
|
|
321
|
-
const askMicro = usdToMicro(safety.ask_threshold_usd);
|
|
322
169
|
const autoMicro = usdToMicro(safety.auto_approve_max_usd);
|
|
323
170
|
if (amountMicro > blockMicro) {
|
|
324
171
|
return { decision: "deny", reason: "BLOCKED_BY_SAFETY_RULE" };
|
|
325
172
|
}
|
|
326
|
-
if (amountMicro >= askMicro) {
|
|
327
|
-
const wallet = await walletLoader();
|
|
328
|
-
const client = clientFactory(wallet);
|
|
329
|
-
const created = await client.request("POST", "/api/agentic-wallet/approval-request", {
|
|
330
|
-
// Server contract (Phase 33): riskLevel MUST be 'ask' or 'block';
|
|
331
|
-
// operationPayload MUST be a non-array object. The hook only creates
|
|
332
|
-
// approval-requests at the ask tier (block tier short-circuits above).
|
|
333
|
-
riskLevel: "ask",
|
|
334
|
-
operationPayload: {
|
|
335
|
-
amountMicroUsdc: amountMicro.toString(),
|
|
336
|
-
contractAddress: contractAddr ?? "",
|
|
337
|
-
toolName: hookInput.tool_name ?? "",
|
|
338
|
-
reason: `Agent tool ${hookInput.tool_name}`
|
|
339
|
-
}
|
|
340
|
-
});
|
|
341
|
-
const approvalId = "_status" in created ? created.approvalRequestId : created.id;
|
|
342
|
-
onAskOpen(`${APPROVAL_URL_BASE}${approvalId}`);
|
|
343
|
-
for (let attempt = 0; attempt < poll.maxAttempts; attempt++) {
|
|
344
|
-
await new Promise((r) => setTimeout(r, poll.intervalMs));
|
|
345
|
-
const status = await client.request("GET", `/api/agentic-wallet/approval-request/${approvalId}`);
|
|
346
|
-
if (!("status" in status)) {
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (status.status === "approved") {
|
|
350
|
-
return { decision: "allow" };
|
|
351
|
-
}
|
|
352
|
-
if (status.status === "rejected") {
|
|
353
|
-
return { decision: "deny", reason: "USER_REJECTED" };
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return { decision: "deny", reason: "APPROVAL_TIMEOUT" };
|
|
357
|
-
}
|
|
358
173
|
if (amountMicro <= autoMicro) {
|
|
359
174
|
return { decision: "allow" };
|
|
360
175
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/hook-entrypoint.ts","../src/hmac.ts","../src/types.ts","../src/client.ts","../src/safety-config.ts","../src/storage.ts","../src/hook.ts"],"sourcesContent":["import { createPreToolUseHook } from \"./hook.js\";\n\n/**\n * Binary entrypoint for `npx @keeperhub/wallet hook` or direct invocation via\n * Claude Code settings.json:\n *\n * { \"type\": \"command\", \"command\": \"npx @keeperhub/wallet hook\", \"timeout\": 30 }\n *\n * Reads JSON from stdin (Claude Code PreToolUse payload), writes the JSON\n * decision envelope to stdout, and exits 2 on deny (the universal \"block\"\n * signal across agent-hook runtimes per the Claude Code docs). A non-JSON\n * stdin is treated as a deny.\n *\n * @security Stdout is RESERVED for the envelope JSON; any diagnostic output\n * (approval URL, errors) goes to stderr via onAskOpen or direct writes.\n */\nexport async function runHookCli(): Promise<void> {\n const hook = await createPreToolUseHook();\n\n let raw = \"\";\n for await (const chunk of process.stdin as unknown as AsyncIterable<Buffer>) {\n raw += chunk.toString(\"utf-8\");\n }\n\n let parsed: unknown;\n try {\n parsed = raw.trim().length > 0 ? JSON.parse(raw) : {};\n } catch (err) {\n process.stderr.write(\n `[keeperhub-wallet] hook input is not valid JSON: ${(err as Error).message}\\n`\n );\n process.exit(2);\n }\n\n const decision = await hook(parsed);\n\n const output = {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\" as const,\n permissionDecision: decision.decision,\n ...(decision.reason ? { permissionDecisionReason: decision.reason } : {}),\n },\n };\n process.stdout.write(JSON.stringify(output));\n process.exit(decision.decision === \"deny\" ? 2 : 0);\n}\n","import { createHash, createHmac } from \"node:crypto\";\nimport type { HmacHeaders } from \"./types.js\";\n\n/**\n * Mirror of lib/agentic-wallet/hmac.ts::computeSignature.\n * Format (byte-for-byte identical to server):\n * `${method}\\n${path}\\n${subOrgId}\\n${sha256_hex(body)}\\n${timestamp}`\n * Post-HI-05: subOrgId is a signed field.\n *\n * @security Do NOT log the secret or the returned signature. Any stdout\n * emitter (the global console object or util.inspect) added to this\n * file is a T-34-08 violation (grep-enforced).\n */\nexport function computeSignature(\n secret: string,\n method: string,\n path: string,\n subOrgId: string,\n body: string,\n timestamp: string\n): string {\n const bodyDigest = createHash(\"sha256\").update(body).digest(\"hex\");\n const signingString = `${method}\\n${path}\\n${subOrgId}\\n${bodyDigest}\\n${timestamp}`;\n return createHmac(\"sha256\", secret).update(signingString).digest(\"hex\");\n}\n\n/**\n * Build the three X-KH-* headers that authenticate every request to\n * /api/agentic-wallet/* (except /provision, which uses the session cookie).\n *\n * Timestamp is unix seconds (Math.floor(Date.now() / 1000)); the server\n * enforces a symmetric 300-second replay window.\n */\nexport function buildHmacHeaders(\n secret: string,\n method: string,\n path: string,\n subOrgId: string,\n body: string\n): HmacHeaders {\n const timestamp = String(Math.floor(Date.now() / 1000));\n const signature = computeSignature(\n secret,\n method,\n path,\n subOrgId,\n body,\n timestamp\n );\n return {\n \"X-KH-Sub-Org\": subOrgId,\n \"X-KH-Timestamp\": timestamp,\n \"X-KH-Signature\": signature,\n };\n}\n","// Shared types across the package. Phase 34.\nexport type WalletConfig = {\n /** Turnkey sub-org ID returned by POST /api/agentic-wallet/provision */\n subOrgId: string;\n /** EVM-shared wallet address (same for Base chainId 8453 and Tempo chainId 4217) */\n walletAddress: `0x${string}`;\n /** 64-char lowercase hex HMAC secret, minted server-side at provision; never logged */\n hmacSecret: string;\n};\n\nexport type HmacHeaders = {\n \"X-KH-Sub-Org\": string;\n \"X-KH-Timestamp\": string;\n \"X-KH-Signature\": string;\n};\n\nexport type HookDecision = {\n decision: \"allow\" | \"deny\" | \"ask\";\n reason?: string;\n};\n\nexport class KeeperHubError extends Error {\n readonly code: string;\n\n constructor(code: string, message: string) {\n super(message);\n this.name = \"KeeperHubError\";\n this.code = code;\n }\n}\n\nexport class WalletConfigMissingError extends Error {\n constructor() {\n super(\n \"Wallet config not found at ~/.keeperhub/wallet.json. Run `npx @keeperhub/wallet add` to provision.\"\n );\n this.name = \"WalletConfigMissingError\";\n }\n}\n","import { buildHmacHeaders } from \"./hmac.js\";\nimport { KeeperHubError, type WalletConfig } from \"./types.js\";\n\nexport type ClientOptions = {\n /** Defaults to process.env.KEEPERHUB_API_URL ?? \"https://app.keeperhub.com\" */\n baseUrl?: string;\n /** Injected for tests; defaults to global fetch */\n fetch?: typeof fetch;\n};\n\n/**\n * 202 ask-tier envelope returned by /sign and /approval-request when the\n * risk classifier routes a request to the ask queue. Callers poll\n * `/api/agentic-wallet/approval-request/:id` until status !== \"pending\".\n */\nexport type AskTierResponse = {\n _status: 202;\n approvalRequestId: string;\n};\n\nconst TRAILING_SLASH = /\\/$/;\n\nfunction defaultCodeForStatus(status: number): string {\n if (status === 401) {\n return \"HMAC_INVALID\";\n }\n if (status === 403) {\n return \"POLICY_BLOCKED\";\n }\n if (status === 404) {\n return \"NOT_FOUND\";\n }\n if (status === 502) {\n return \"TURNKEY_UPSTREAM\";\n }\n return `HTTP_${status}`;\n}\n\n/**\n * HMAC-signed HTTP client for the KeeperHub agentic-wallet API surface.\n * Every request to /api/agentic-wallet/* (except /provision, which uses\n * the session cookie) flows through this class.\n *\n * @security No logging of headers, body, or response bodies. Any stdout\n * emitter (the global console object or util.inspect) added to this\n * file is a T-34-08 violation (grep-enforced in CI).\n */\nexport class KeeperHubClient {\n private readonly baseUrl: string;\n private readonly fetchImpl: typeof fetch;\n private readonly wallet: WalletConfig;\n\n constructor(wallet: WalletConfig, opts: ClientOptions = {}) {\n this.wallet = wallet;\n const envBase = process.env.KEEPERHUB_API_URL;\n this.baseUrl = (\n opts.baseUrl ??\n envBase ??\n \"https://app.keeperhub.com\"\n ).replace(TRAILING_SLASH, \"\");\n this.fetchImpl = opts.fetch ?? globalThis.fetch;\n }\n\n /**\n * HMAC-signed POST/GET to any /api/agentic-wallet/* route except\n * /provision. Path MUST start with a leading slash. Body is\n * JSON.stringify'd (or the empty string for GET).\n *\n * Error mapping: non-2xx/non-202 surface as `KeeperHubError(code,\n * message)` where `code` is the server-supplied field or the default\n * taxonomy (`HMAC_INVALID`, `POLICY_BLOCKED`, `NOT_FOUND`,\n * `TURNKEY_UPSTREAM`, `HTTP_<status>`). 202 ask-tier surfaces as an\n * AskTierResponse envelope.\n */\n async request<T>(\n method: \"GET\" | \"POST\",\n path: string,\n body?: unknown\n ): Promise<T | AskTierResponse> {\n const bodyStr = body === undefined ? \"\" : JSON.stringify(body);\n const hmacHeaders = buildHmacHeaders(\n this.wallet.hmacSecret,\n method,\n path,\n this.wallet.subOrgId,\n bodyStr\n );\n const headers: Record<string, string> =\n method === \"POST\"\n ? { ...hmacHeaders, \"content-type\": \"application/json\" }\n : { ...hmacHeaders };\n const response = await this.fetchImpl(`${this.baseUrl}${path}`, {\n method,\n headers,\n body: method === \"POST\" ? bodyStr : undefined,\n });\n\n if (response.status === 202) {\n const data = (await response.json()) as {\n approvalRequestId: string;\n status: string;\n };\n return { _status: 202, approvalRequestId: data.approvalRequestId };\n }\n\n if (!response.ok) {\n let code = \"UNKNOWN\";\n let message = `HTTP ${response.status}`;\n try {\n const data = (await response.json()) as {\n code?: string;\n error?: string;\n };\n code = data.code ?? defaultCodeForStatus(response.status);\n message = data.error ?? message;\n } catch {\n // body is not JSON -- keep the default code + message\n }\n throw new KeeperHubError(code, message);\n }\n\n return (await response.json()) as T;\n }\n}\n","import { chmod, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\n/**\n * User-owned safety config at ~/.keeperhub/safety.json. File mode 0o644 so the\n * user can freely edit thresholds and the allowlist; server-side Turnkey policy\n * remains the authoritative hard cap (GUARD-06).\n */\nexport type SafetyConfig = {\n auto_approve_max_usd: number;\n ask_threshold_usd: number;\n block_threshold_usd: number;\n allowlisted_contracts: string[];\n};\n\n/**\n * Defaults per 34-CONTEXT lines 61-68. Thresholds bracket the Turnkey policy\n * hard cap (100 USDC). Allowlisted contracts mirror the server Turnkey policy\n * allowlist (lib/agentic-wallet/policy.ts FACILITATOR_ALLOWLIST) -- lowercased\n * for case-insensitive match against tool_input.to / paymentChallenge.payTo.\n */\nexport const DEFAULT_SAFETY_CONFIG: SafetyConfig = {\n auto_approve_max_usd: 5,\n ask_threshold_usd: 50,\n block_threshold_usd: 100,\n allowlisted_contracts: [\n \"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913\", // Base USDC\n \"0x20c000000000000000000000b9537d11c60e8b50\", // Tempo USDC.e\n ],\n};\n\n// NOTE: Every function calls `join(homedir(), \".keeperhub\", \"safety.json\")`\n// itself -- matches storage.ts. Hoisting to a module-level constant would\n// freeze $HOME at import time and break tests that override process.env.HOME\n// in beforeEach.\n\nfunction getSafetyPath(): string {\n return join(homedir(), \".keeperhub\", \"safety.json\");\n}\n\nexport async function loadSafetyConfig(): Promise<SafetyConfig> {\n const path = getSafetyPath();\n let raw: string;\n try {\n raw = await readFile(path, \"utf-8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n await mkdir(dirname(path), { recursive: true, mode: 0o700 });\n await writeFile(path, JSON.stringify(DEFAULT_SAFETY_CONFIG, null, 2), {\n mode: 0o644,\n });\n // Reassert mode in case the file already existed with looser perms.\n await chmod(path, 0o644);\n return DEFAULT_SAFETY_CONFIG;\n }\n throw err;\n }\n const parsed = JSON.parse(raw) as Partial<SafetyConfig>;\n return validateAndMerge(parsed);\n}\n\nconst THRESHOLD_KEYS = [\n \"auto_approve_max_usd\",\n \"ask_threshold_usd\",\n \"block_threshold_usd\",\n] as const;\n\nexport function validateAndMerge(partial: Partial<SafetyConfig>): SafetyConfig {\n const merged: SafetyConfig = {\n auto_approve_max_usd:\n partial.auto_approve_max_usd ??\n DEFAULT_SAFETY_CONFIG.auto_approve_max_usd,\n ask_threshold_usd:\n partial.ask_threshold_usd ?? DEFAULT_SAFETY_CONFIG.ask_threshold_usd,\n block_threshold_usd:\n partial.block_threshold_usd ?? DEFAULT_SAFETY_CONFIG.block_threshold_usd,\n allowlisted_contracts:\n partial.allowlisted_contracts ??\n DEFAULT_SAFETY_CONFIG.allowlisted_contracts,\n };\n\n for (const key of THRESHOLD_KEYS) {\n const v = merged[key];\n if (!(Number.isFinite(v) && v >= 0)) {\n throw new Error(\n `safety.json: ${key} must be a non-negative finite number; got ${String(v)}`\n );\n }\n }\n if (merged.ask_threshold_usd < merged.auto_approve_max_usd) {\n throw new Error(\n \"safety.json: ask_threshold_usd must be >= auto_approve_max_usd\"\n );\n }\n if (merged.block_threshold_usd < merged.ask_threshold_usd) {\n throw new Error(\n \"safety.json: block_threshold_usd must be >= ask_threshold_usd\"\n );\n }\n if (!Array.isArray(merged.allowlisted_contracts)) {\n throw new Error(\"safety.json: allowlisted_contracts must be an array\");\n }\n merged.allowlisted_contracts = merged.allowlisted_contracts.map((a) =>\n a.toLowerCase()\n );\n return merged;\n}\n\nexport function getSafetyConfigPath(): string {\n return getSafetyPath();\n}\n","import { chmod, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { type WalletConfig, WalletConfigMissingError } from \"./types.js\";\n\n// NOTE: Every function calls `join(homedir(), \".keeperhub\", \"wallet.json\")`\n// itself. Do NOT hoist to a module-level `const WALLET_PATH` -- tests\n// override `process.env.HOME` in `beforeEach` and `homedir()` must re-read\n// that on each call. A hoisted constant would freeze the harness's original\n// HOME at import time and every test would write into the real\n// ~/.keeperhub/ directory.\n\nexport async function readWalletConfig(): Promise<WalletConfig> {\n const walletPath = join(homedir(), \".keeperhub\", \"wallet.json\");\n let raw: string;\n try {\n raw = await readFile(walletPath, \"utf-8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n throw new WalletConfigMissingError();\n }\n throw err;\n }\n const parsed = JSON.parse(raw) as Partial<WalletConfig>;\n if (!(parsed.subOrgId && parsed.walletAddress && parsed.hmacSecret)) {\n throw new Error(`Malformed wallet.json at ${walletPath}`);\n }\n return parsed as WalletConfig;\n}\n\nexport async function writeWalletConfig(config: WalletConfig): Promise<void> {\n const walletPath = join(homedir(), \".keeperhub\", \"wallet.json\");\n await mkdir(dirname(walletPath), { recursive: true, mode: 0o700 });\n await writeFile(walletPath, JSON.stringify(config, null, 2), { mode: 0o600 });\n // Reassert mode in case the file already existed with looser perms.\n await chmod(walletPath, 0o600);\n}\n\nexport function getWalletConfigPath(): string {\n return join(homedir(), \".keeperhub\", \"wallet.json\");\n}\n","import { KeeperHubClient } from \"./client.js\";\nimport { loadSafetyConfig, type SafetyConfig } from \"./safety-config.js\";\nimport { readWalletConfig } from \"./storage.js\";\nimport type { HookDecision, WalletConfig } from \"./types.js\";\n\ntype HookInput = {\n tool_name?: string;\n tool_input?: Record<string, unknown>;\n};\n\nexport type CreateHookOptions = {\n /** Match against tool_name. Default: /keeperhub|wallet|sign/i */\n toolNameMatcher?: (name: string) => boolean;\n /** Injected for tests */\n walletLoader?: () => Promise<WalletConfig>;\n /** Injected for tests */\n configLoader?: () => Promise<SafetyConfig>;\n /** Injected for tests */\n clientFactory?: (w: WalletConfig) => KeeperHubClient;\n /**\n * Called when the ask tier opens an approval URL. Default: write to stderr\n * (stdout is reserved for the Claude Code hook JSON output).\n */\n onAskOpen?: (url: string) => void;\n /** Polling config for the ask tier */\n poll?: { intervalMs: number; maxAttempts: number };\n};\n\nconst DEFAULT_POLL = { intervalMs: 2000, maxAttempts: 150 } as const;\nconst APPROVAL_URL_BASE = \"https://app.keeperhub.com/approve/\";\nconst USDC_DECIMALS = 1_000_000;\nconst ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;\nconst MICRO_USDC_RE = /^\\d+$/;\nconst DEFAULT_TOOL_RE = /keeperhub|wallet|sign/i;\n\nfunction defaultToolMatcher(name: string): boolean {\n return DEFAULT_TOOL_RE.test(name);\n}\n\n/**\n * Coerce an amount field to micro-USDC. Inputs MUST be explicitly tagged with\n * `unit`:\n * - `{amount: string, unit: \"microUsdc\"}` -> parsed as integer micro-USDC\n * (x402 wire format)\n * - `{amount: number, unit: \"usd\"}` -> multiplied by 1_000_000\n *\n * Untagged amounts are REJECTED with a thrown TypeError. This is GUARD-05:\n * we refuse to guess whether a \"5\" is 5 USD or 5 micro-USDC (a six-order-of-\n * magnitude reading error). The caller must commit.\n *\n * Fields read: ONLY tool_input.paymentChallenge.{amount,unit} and\n * tool_input.{amount,unit}. Forged safety-bypass fields (any \"trust-level\"\n * hint, \"is-safe\" boolean, \"admin-override\" bit, or similar) are NEVER read;\n * thresholds come exclusively from ~/.keeperhub/safety.json.\n */\nfunction extractAmountMicroUsdc(input: HookInput): bigint | null {\n const ti = input.tool_input ?? {};\n const challenge = (ti.paymentChallenge ?? {}) as Record<string, unknown>;\n // WR-01: prefer the signed wire field (paymentChallenge.amount/unit) over\n // caller-supplied sibling tool_input fields. The nested challenge is the\n // field the downstream /sign call actually binds into the signed bytes, so\n // a misbehaving tool cannot slip a larger nested amount past the auto cap\n // by shadowing it with a small top-level sibling. Fall back to top-level\n // only when no challenge is present (e.g. direct /sign tool calls with no\n // 402 round).\n const directAmount = challenge.amount ?? ti.amount;\n const directUnit = challenge.unit ?? ti.unit;\n\n if (directAmount === undefined || directAmount === null) {\n return null;\n }\n if (directUnit !== \"usd\" && directUnit !== \"microUsdc\") {\n throw new TypeError(\n `Amount input must be tagged with unit:\"usd\" or unit:\"microUsdc\"; got unit=${JSON.stringify(directUnit)}. GUARD-05 refuses to guess - specify explicitly.`\n );\n }\n if (directUnit === \"microUsdc\") {\n if (\n !(typeof directAmount === \"string\" && MICRO_USDC_RE.test(directAmount))\n ) {\n throw new TypeError(\n `unit:\"microUsdc\" requires amount as a non-negative integer string; got ${typeof directAmount}`\n );\n }\n return BigInt(directAmount);\n }\n // unit === \"usd\"\n if (\n !(\n typeof directAmount === \"number\" &&\n Number.isFinite(directAmount) &&\n directAmount >= 0\n )\n ) {\n throw new TypeError(\n `unit:\"usd\" requires amount as a finite non-negative number; got ${typeof directAmount}`\n );\n }\n return BigInt(Math.round(directAmount * USDC_DECIMALS));\n}\n\nfunction extractContractAddress(input: HookInput): string | null {\n const ti = input.tool_input ?? {};\n const challenge = (ti.paymentChallenge ?? {}) as Record<string, unknown>;\n // Precedence order:\n // 1. challenge.asset — x402 TransferWithAuthorization: the ERC-20 contract\n // the authorization is bound to (the EVM `eth.tx.to` at execution time).\n // This mirrors the server-side Turnkey policy (policy.ts) which denies\n // `eth.tx.to not in [USDC_BASE, USDC_TEMPO]`.\n // 2. ti.contract / ti.assetAddress — agent-runtime-supplied hints.\n // 3. ti.to / challenge.to — legacy tool_inputs that labeled the asset as\n // \"to\" (some older MCP implementations). Kept for backwards compat.\n // NEVER reads challenge.payTo: that is the transfer recipient (the\n // facilitator or service operator), not the ERC-20 contract being invoked.\n const contract =\n challenge.asset ??\n ti.contract ??\n ti.assetAddress ??\n ti.to ??\n challenge.to;\n if (typeof contract === \"string\" && ADDRESS_RE.test(contract)) {\n return contract.toLowerCase();\n }\n return null;\n}\n\nfunction usdToMicro(usd: number): bigint {\n return BigInt(Math.round(usd * USDC_DECIMALS));\n}\n\n/**\n * Factory returning the PreToolUse hook function. The hook enforces the three\n * client-side safety tiers (auto / ask / block) sourced EXCLUSIVELY from\n * ~/.keeperhub/safety.json -- never from the tool payload (GUARD-05).\n */\nexport async function createPreToolUseHook(\n options: CreateHookOptions = {}\n): Promise<(input: unknown) => Promise<HookDecision>> {\n const toolMatcher = options.toolNameMatcher ?? defaultToolMatcher;\n const configLoader = options.configLoader ?? loadSafetyConfig;\n const walletLoader = options.walletLoader ?? readWalletConfig;\n const clientFactory =\n options.clientFactory ?? ((w: WalletConfig) => new KeeperHubClient(w));\n const onAskOpen =\n options.onAskOpen ??\n ((url: string): void => {\n process.stderr.write(\n `\\n[keeperhub-wallet] Approval required. Visit: ${url}\\n`\n );\n });\n const poll = options.poll ?? DEFAULT_POLL;\n\n const safety = await configLoader();\n\n return async (raw: unknown): Promise<HookDecision> => {\n const hookInput = (raw ?? {}) as HookInput;\n\n // Pass-through for non-wallet tool calls.\n if (\n !(\n typeof hookInput.tool_name === \"string\" &&\n toolMatcher(hookInput.tool_name)\n )\n ) {\n return { decision: \"allow\" };\n }\n\n // GUARD-05: ONLY these fields. No trust/override/admin_* reads.\n const contractAddr = extractContractAddress(hookInput);\n const amountMicro = extractAmountMicroUsdc(hookInput);\n\n if (contractAddr && !safety.allowlisted_contracts.includes(contractAddr)) {\n return { decision: \"deny\", reason: \"CONTRACT_NOT_ALLOWLISTED\" };\n }\n\n if (amountMicro === null) {\n return { decision: \"deny\", reason: \"AMOUNT_UNDETERMINED\" };\n }\n\n const blockMicro = usdToMicro(safety.block_threshold_usd);\n const askMicro = usdToMicro(safety.ask_threshold_usd);\n const autoMicro = usdToMicro(safety.auto_approve_max_usd);\n\n if (amountMicro > blockMicro) {\n return { decision: \"deny\", reason: \"BLOCKED_BY_SAFETY_RULE\" };\n }\n\n if (amountMicro >= askMicro) {\n // Open approval flow (create approval-request + poll until non-pending).\n const wallet = await walletLoader();\n const client = clientFactory(wallet);\n // Server returns `{id}` on create; poll endpoint returns `{status}`.\n const created = await client.request<{\n id: string;\n }>(\"POST\", \"/api/agentic-wallet/approval-request\", {\n // Server contract (Phase 33): riskLevel MUST be 'ask' or 'block';\n // operationPayload MUST be a non-array object. The hook only creates\n // approval-requests at the ask tier (block tier short-circuits above).\n riskLevel: \"ask\",\n operationPayload: {\n amountMicroUsdc: amountMicro.toString(),\n contractAddress: contractAddr ?? \"\",\n toolName: hookInput.tool_name ?? \"\",\n reason: `Agent tool ${hookInput.tool_name}`,\n },\n });\n const approvalId =\n \"_status\" in created ? created.approvalRequestId : created.id;\n onAskOpen(`${APPROVAL_URL_BASE}${approvalId}`);\n for (let attempt = 0; attempt < poll.maxAttempts; attempt++) {\n await new Promise<void>((r) => setTimeout(r, poll.intervalMs));\n const status = await client.request<{\n status: \"pending\" | \"approved\" | \"rejected\";\n }>(\"GET\", `/api/agentic-wallet/approval-request/${approvalId}`);\n // WR-03: align with payment-signer.ts:84 -- positive-shape check on\n // the expected envelope. If a future server change or proxy returns\n // a 202/other wrapper without `status`, keep polling instead of\n // treating an unknown envelope as \"pending\" by implication.\n if (!(\"status\" in status)) {\n continue;\n }\n if (status.status === \"approved\") {\n return { decision: \"allow\" };\n }\n if (status.status === \"rejected\") {\n return { decision: \"deny\", reason: \"USER_REJECTED\" };\n }\n // status === \"pending\" -- continue polling.\n }\n return { decision: \"deny\", reason: \"APPROVAL_TIMEOUT\" };\n }\n\n if (amountMicro <= autoMicro) {\n return { decision: \"allow\" };\n }\n\n // Middle band: above auto but below ask.\n return { decision: \"ask\" };\n };\n}\n"],"mappings":"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","names":["import_promises","import_node_os","import_node_path"]}
|
|
1
|
+
{"version":3,"sources":["../src/hook-entrypoint.ts","../src/safety-config.ts","../src/hook.ts"],"sourcesContent":["import { createPreToolUseHook } from \"./hook.js\";\n\n/**\n * Binary entrypoint for `npx @keeperhub/wallet hook` or direct invocation via\n * Claude Code settings.json:\n *\n * { \"type\": \"command\", \"command\": \"npx @keeperhub/wallet hook\", \"timeout\": 30 }\n *\n * Reads JSON from stdin (Claude Code PreToolUse payload), writes the JSON\n * decision envelope to stdout, and exits 2 on deny (the universal \"block\"\n * signal across agent-hook runtimes per the Claude Code docs). A non-JSON\n * stdin is treated as a deny.\n *\n * @security Stdout is RESERVED for the envelope JSON; any diagnostic output\n * (approval URL, errors) goes to stderr via onAskOpen or direct writes.\n */\nexport async function runHookCli(): Promise<void> {\n const hook = await createPreToolUseHook();\n\n let raw = \"\";\n for await (const chunk of process.stdin as unknown as AsyncIterable<Buffer>) {\n raw += chunk.toString(\"utf-8\");\n }\n\n let parsed: unknown;\n try {\n parsed = raw.trim().length > 0 ? JSON.parse(raw) : {};\n } catch (err) {\n process.stderr.write(\n `[keeperhub-wallet] hook input is not valid JSON: ${(err as Error).message}\\n`\n );\n process.exit(2);\n }\n\n const decision = await hook(parsed);\n\n const output = {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\" as const,\n permissionDecision: decision.decision,\n ...(decision.reason ? { permissionDecisionReason: decision.reason } : {}),\n },\n };\n process.stdout.write(JSON.stringify(output));\n process.exit(decision.decision === \"deny\" ? 2 : 0);\n}\n","import { chmod, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\n/**\n * User-owned safety config at ~/.keeperhub/safety.json. File mode 0o644 so the\n * user can freely edit thresholds and the allowlist; server-side Turnkey policy\n * remains the authoritative hard cap (GUARD-06).\n */\nexport type SafetyConfig = {\n auto_approve_max_usd: number;\n ask_threshold_usd: number;\n block_threshold_usd: number;\n allowlisted_contracts: string[];\n};\n\n/**\n * Defaults per 34-CONTEXT lines 61-68. Thresholds bracket the Turnkey policy\n * hard cap (100 USDC). Allowlisted contracts mirror the server Turnkey policy\n * allowlist (lib/agentic-wallet/policy.ts FACILITATOR_ALLOWLIST) -- lowercased\n * for case-insensitive match against tool_input.to / paymentChallenge.payTo.\n */\nexport const DEFAULT_SAFETY_CONFIG: SafetyConfig = {\n auto_approve_max_usd: 5,\n ask_threshold_usd: 50,\n block_threshold_usd: 100,\n allowlisted_contracts: [\n \"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913\", // Base USDC\n \"0x20c000000000000000000000b9537d11c60e8b50\", // Tempo USDC.e\n ],\n};\n\n// NOTE: Every function calls `join(homedir(), \".keeperhub\", \"safety.json\")`\n// itself -- matches storage.ts. Hoisting to a module-level constant would\n// freeze $HOME at import time and break tests that override process.env.HOME\n// in beforeEach.\n\nfunction getSafetyPath(): string {\n return join(homedir(), \".keeperhub\", \"safety.json\");\n}\n\nexport async function loadSafetyConfig(): Promise<SafetyConfig> {\n const path = getSafetyPath();\n let raw: string;\n try {\n raw = await readFile(path, \"utf-8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n await mkdir(dirname(path), { recursive: true, mode: 0o700 });\n await writeFile(path, JSON.stringify(DEFAULT_SAFETY_CONFIG, null, 2), {\n mode: 0o644,\n });\n // Reassert mode in case the file already existed with looser perms.\n await chmod(path, 0o644);\n return DEFAULT_SAFETY_CONFIG;\n }\n throw err;\n }\n const parsed = JSON.parse(raw) as Partial<SafetyConfig>;\n return validateAndMerge(parsed);\n}\n\nconst THRESHOLD_KEYS = [\n \"auto_approve_max_usd\",\n \"ask_threshold_usd\",\n \"block_threshold_usd\",\n] as const;\n\nexport function validateAndMerge(partial: Partial<SafetyConfig>): SafetyConfig {\n const merged: SafetyConfig = {\n auto_approve_max_usd:\n partial.auto_approve_max_usd ??\n DEFAULT_SAFETY_CONFIG.auto_approve_max_usd,\n ask_threshold_usd:\n partial.ask_threshold_usd ?? DEFAULT_SAFETY_CONFIG.ask_threshold_usd,\n block_threshold_usd:\n partial.block_threshold_usd ?? DEFAULT_SAFETY_CONFIG.block_threshold_usd,\n allowlisted_contracts:\n partial.allowlisted_contracts ??\n DEFAULT_SAFETY_CONFIG.allowlisted_contracts,\n };\n\n for (const key of THRESHOLD_KEYS) {\n const v = merged[key];\n if (!(Number.isFinite(v) && v >= 0)) {\n throw new Error(\n `safety.json: ${key} must be a non-negative finite number; got ${String(v)}`\n );\n }\n }\n if (merged.ask_threshold_usd < merged.auto_approve_max_usd) {\n throw new Error(\n \"safety.json: ask_threshold_usd must be >= auto_approve_max_usd\"\n );\n }\n if (merged.block_threshold_usd < merged.ask_threshold_usd) {\n throw new Error(\n \"safety.json: block_threshold_usd must be >= ask_threshold_usd\"\n );\n }\n if (!Array.isArray(merged.allowlisted_contracts)) {\n throw new Error(\"safety.json: allowlisted_contracts must be an array\");\n }\n merged.allowlisted_contracts = merged.allowlisted_contracts.map((a) =>\n a.toLowerCase()\n );\n return merged;\n}\n\nexport function getSafetyConfigPath(): string {\n return getSafetyPath();\n}\n","import { loadSafetyConfig, type SafetyConfig } from \"./safety-config.js\";\nimport type { HookDecision } from \"./types.js\";\n\ntype HookInput = {\n tool_name?: string;\n tool_input?: Record<string, unknown>;\n};\n\nexport type CreateHookOptions = {\n /** Match against tool_name. Default: /keeperhub|wallet|sign/i */\n toolNameMatcher?: (name: string) => boolean;\n /** Injected for tests */\n configLoader?: () => Promise<SafetyConfig>;\n};\n\nconst USDC_DECIMALS = 1_000_000;\nconst ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;\nconst MICRO_USDC_RE = /^\\d+$/;\nconst DEFAULT_TOOL_RE = /keeperhub|wallet|sign/i;\n\nfunction defaultToolMatcher(name: string): boolean {\n return DEFAULT_TOOL_RE.test(name);\n}\n\n/**\n * Coerce an amount field to micro-USDC. Inputs MUST be explicitly tagged with\n * `unit`:\n * - `{amount: string, unit: \"microUsdc\"}` -> parsed as integer micro-USDC\n * (x402 wire format)\n * - `{amount: number, unit: \"usd\"}` -> multiplied by 1_000_000\n *\n * Untagged amounts are REJECTED with a thrown TypeError. This is GUARD-05:\n * we refuse to guess whether a \"5\" is 5 USD or 5 micro-USDC (a six-order-of-\n * magnitude reading error). The caller must commit.\n *\n * Fields read: ONLY tool_input.paymentChallenge.{amount,unit} and\n * tool_input.{amount,unit}. Forged safety-bypass fields (any \"trust-level\"\n * hint, \"is-safe\" boolean, \"admin-override\" bit, or similar) are NEVER read;\n * thresholds come exclusively from ~/.keeperhub/safety.json.\n */\nfunction extractAmountMicroUsdc(input: HookInput): bigint | null {\n const ti = input.tool_input ?? {};\n const challenge = (ti.paymentChallenge ?? {}) as Record<string, unknown>;\n // WR-01: prefer the signed wire field (paymentChallenge.amount/unit) over\n // caller-supplied sibling tool_input fields. The nested challenge is the\n // field the downstream /sign call actually binds into the signed bytes, so\n // a misbehaving tool cannot slip a larger nested amount past the auto cap\n // by shadowing it with a small top-level sibling. Fall back to top-level\n // only when no challenge is present (e.g. direct /sign tool calls with no\n // 402 round).\n const directAmount = challenge.amount ?? ti.amount;\n const directUnit = challenge.unit ?? ti.unit;\n\n if (directAmount === undefined || directAmount === null) {\n return null;\n }\n if (directUnit !== \"usd\" && directUnit !== \"microUsdc\") {\n throw new TypeError(\n `Amount input must be tagged with unit:\"usd\" or unit:\"microUsdc\"; got unit=${JSON.stringify(directUnit)}. GUARD-05 refuses to guess - specify explicitly.`\n );\n }\n if (directUnit === \"microUsdc\") {\n if (\n !(typeof directAmount === \"string\" && MICRO_USDC_RE.test(directAmount))\n ) {\n throw new TypeError(\n `unit:\"microUsdc\" requires amount as a non-negative integer string; got ${typeof directAmount}`\n );\n }\n return BigInt(directAmount);\n }\n // unit === \"usd\"\n if (\n !(\n typeof directAmount === \"number\" &&\n Number.isFinite(directAmount) &&\n directAmount >= 0\n )\n ) {\n throw new TypeError(\n `unit:\"usd\" requires amount as a finite non-negative number; got ${typeof directAmount}`\n );\n }\n return BigInt(Math.round(directAmount * USDC_DECIMALS));\n}\n\nfunction extractContractAddress(input: HookInput): string | null {\n const ti = input.tool_input ?? {};\n const challenge = (ti.paymentChallenge ?? {}) as Record<string, unknown>;\n // Precedence order:\n // 1. challenge.asset -- x402 TransferWithAuthorization: the ERC-20 contract\n // the authorization is bound to (the EVM `eth.tx.to` at execution time).\n // This mirrors the server-side Turnkey policy (policy.ts) which denies\n // `eth.tx.to not in [USDC_BASE, USDC_TEMPO]`.\n // 2. ti.contract / ti.assetAddress -- agent-runtime-supplied hints.\n // 3. ti.to / challenge.to -- legacy tool_inputs that labeled the asset as\n // \"to\" (some older MCP implementations). Kept for backwards compat.\n // NEVER reads challenge.payTo: that is the transfer recipient (the\n // facilitator or service operator), not the ERC-20 contract being invoked.\n const contract =\n challenge.asset ??\n ti.contract ??\n ti.assetAddress ??\n ti.to ??\n challenge.to;\n if (typeof contract === \"string\" && ADDRESS_RE.test(contract)) {\n return contract.toLowerCase();\n }\n return null;\n}\n\nfunction usdToMicro(usd: number): bigint {\n return BigInt(Math.round(usd * USDC_DECIMALS));\n}\n\n/**\n * Factory returning the PreToolUse hook function. The hook enforces three\n * client-side safety tiers (auto / ask / block) sourced EXCLUSIVELY from\n * ~/.keeperhub/safety.json -- never from the tool payload (GUARD-05).\n *\n * v0.1.4 collapsed the previous four-band behaviour into three:\n *\n * amount <= auto_approve_max_usd -> {decision: \"allow\"}\n * auto_approve_max_usd < amount <= block_threshold -> {decision: \"ask\"} (Claude Code prompts user inline)\n * amount > block_threshold -> {decision: \"deny\"}\n *\n * The previous server-approval branch (amount >= ask_threshold -> create a\n * /api/agentic-wallet/approval-request row, print an approval URL, poll for\n * browser approval) was removed. It required the wallet to be linked to a\n * KeeperHub user via /link, and the link command was rough enough that we\n * never wired it into the documented flow. Returning {decision: \"ask\"}\n * inline lets Claude Code surface the prompt in the agent chat directly.\n *\n * `ask_threshold_usd` is still read from safety.json for backward-compat\n * with existing configs but is not consulted for decision-making. Tracked\n * as KEEP-307 for the permanent architectural decision.\n */\nexport async function createPreToolUseHook(\n options: CreateHookOptions = {}\n): Promise<(input: unknown) => Promise<HookDecision>> {\n const toolMatcher = options.toolNameMatcher ?? defaultToolMatcher;\n const configLoader = options.configLoader ?? loadSafetyConfig;\n\n const safety = await configLoader();\n\n // The hook function is declared async so that synchronous throws in\n // extractAmountMicroUsdc (GUARD-05 unit-tag enforcement) become rejected\n // promises, matching the original pre-0.1.4 behaviour the tests rely on.\n return async (raw: unknown): Promise<HookDecision> => {\n const hookInput = (raw ?? {}) as HookInput;\n\n // Pass-through for non-wallet tool calls.\n if (\n !(\n typeof hookInput.tool_name === \"string\" &&\n toolMatcher(hookInput.tool_name)\n )\n ) {\n return { decision: \"allow\" };\n }\n\n // GUARD-05: ONLY these fields. No trust/override/admin_* reads.\n const contractAddr = extractContractAddress(hookInput);\n const amountMicro = extractAmountMicroUsdc(hookInput);\n\n if (contractAddr && !safety.allowlisted_contracts.includes(contractAddr)) {\n return { decision: \"deny\", reason: \"CONTRACT_NOT_ALLOWLISTED\" };\n }\n\n if (amountMicro === null) {\n return { decision: \"deny\", reason: \"AMOUNT_UNDETERMINED\" };\n }\n\n const blockMicro = usdToMicro(safety.block_threshold_usd);\n const autoMicro = usdToMicro(safety.auto_approve_max_usd);\n\n if (amountMicro > blockMicro) {\n return { decision: \"deny\", reason: \"BLOCKED_BY_SAFETY_RULE\" };\n }\n\n if (amountMicro <= autoMicro) {\n return { decision: \"allow\" };\n }\n\n // Everything between auto and block is an inline ask -- Claude Code\n // surfaces the prompt in-chat.\n return { decision: \"ask\" };\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAAkD;AAClD,qBAAwB;AACxB,uBAA8B;AAoBvB,IAAM,wBAAsC;AAAA,EACjD,sBAAsB;AAAA,EACtB,mBAAmB;AAAA,EACnB,qBAAqB;AAAA,EACrB,uBAAuB;AAAA,IACrB;AAAA;AAAA,IACA;AAAA;AAAA,EACF;AACF;AAOA,SAAS,gBAAwB;AAC/B,aAAO,2BAAK,wBAAQ,GAAG,cAAc,aAAa;AACpD;AAEA,eAAsB,mBAA0C;AAC9D,QAAM,OAAO,cAAc;AAC3B,MAAI;AACJ,MAAI;AACF,UAAM,UAAM,0BAAS,MAAM,OAAO;AAAA,EACpC,SAAS,KAAK;AACZ,QAAK,IAA8B,SAAS,UAAU;AACpD,gBAAM,2BAAM,0BAAQ,IAAI,GAAG,EAAE,WAAW,MAAM,MAAM,IAAM,CAAC;AAC3D,gBAAM,2BAAU,MAAM,KAAK,UAAU,uBAAuB,MAAM,CAAC,GAAG;AAAA,QACpE,MAAM;AAAA,MACR,CAAC;AAED,gBAAM,uBAAM,MAAM,GAAK;AACvB,aAAO;AAAA,IACT;AACA,UAAM;AAAA,EACR;AACA,QAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,SAAO,iBAAiB,MAAM;AAChC;AAEA,IAAM,iBAAiB;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AACF;AAEO,SAAS,iBAAiB,SAA8C;AAC7E,QAAM,SAAuB;AAAA,IAC3B,sBACE,QAAQ,wBACR,sBAAsB;AAAA,IACxB,mBACE,QAAQ,qBAAqB,sBAAsB;AAAA,IACrD,qBACE,QAAQ,uBAAuB,sBAAsB;AAAA,IACvD,uBACE,QAAQ,yBACR,sBAAsB;AAAA,EAC1B;AAEA,aAAW,OAAO,gBAAgB;AAChC,UAAM,IAAI,OAAO,GAAG;AACpB,QAAI,EAAE,OAAO,SAAS,CAAC,KAAK,KAAK,IAAI;AACnC,YAAM,IAAI;AAAA,QACR,gBAAgB,GAAG,8CAA8C,OAAO,CAAC,CAAC;AAAA,MAC5E;AAAA,IACF;AAAA,EACF;AACA,MAAI,OAAO,oBAAoB,OAAO,sBAAsB;AAC1D,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,MAAI,OAAO,sBAAsB,OAAO,mBAAmB;AACzD,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,MAAM,QAAQ,OAAO,qBAAqB,GAAG;AAChD,UAAM,IAAI,MAAM,qDAAqD;AAAA,EACvE;AACA,SAAO,wBAAwB,OAAO,sBAAsB;AAAA,IAAI,CAAC,MAC/D,EAAE,YAAY;AAAA,EAChB;AACA,SAAO;AACT;;;AC5FA,IAAM,gBAAgB;AACtB,IAAM,aAAa;AACnB,IAAM,gBAAgB;AACtB,IAAM,kBAAkB;AAExB,SAAS,mBAAmB,MAAuB;AACjD,SAAO,gBAAgB,KAAK,IAAI;AAClC;AAkBA,SAAS,uBAAuB,OAAiC;AAC/D,QAAM,KAAK,MAAM,cAAc,CAAC;AAChC,QAAM,YAAa,GAAG,oBAAoB,CAAC;AAQ3C,QAAM,eAAe,UAAU,UAAU,GAAG;AAC5C,QAAM,aAAa,UAAU,QAAQ,GAAG;AAExC,MAAI,iBAAiB,UAAa,iBAAiB,MAAM;AACvD,WAAO;AAAA,EACT;AACA,MAAI,eAAe,SAAS,eAAe,aAAa;AACtD,UAAM,IAAI;AAAA,MACR,6EAA6E,KAAK,UAAU,UAAU,CAAC;AAAA,IACzG;AAAA,EACF;AACA,MAAI,eAAe,aAAa;AAC9B,QACE,EAAE,OAAO,iBAAiB,YAAY,cAAc,KAAK,YAAY,IACrE;AACA,YAAM,IAAI;AAAA,QACR,0EAA0E,OAAO,YAAY;AAAA,MAC/F;AAAA,IACF;AACA,WAAO,OAAO,YAAY;AAAA,EAC5B;AAEA,MACE,EACE,OAAO,iBAAiB,YACxB,OAAO,SAAS,YAAY,KAC5B,gBAAgB,IAElB;AACA,UAAM,IAAI;AAAA,MACR,mEAAmE,OAAO,YAAY;AAAA,IACxF;AAAA,EACF;AACA,SAAO,OAAO,KAAK,MAAM,eAAe,aAAa,CAAC;AACxD;AAEA,SAAS,uBAAuB,OAAiC;AAC/D,QAAM,KAAK,MAAM,cAAc,CAAC;AAChC,QAAM,YAAa,GAAG,oBAAoB,CAAC;AAW3C,QAAM,WACJ,UAAU,SACV,GAAG,YACH,GAAG,gBACH,GAAG,MACH,UAAU;AACZ,MAAI,OAAO,aAAa,YAAY,WAAW,KAAK,QAAQ,GAAG;AAC7D,WAAO,SAAS,YAAY;AAAA,EAC9B;AACA,SAAO;AACT;AAEA,SAAS,WAAW,KAAqB;AACvC,SAAO,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAC/C;AAwBA,eAAsB,qBACpB,UAA6B,CAAC,GACsB;AACpD,QAAM,cAAc,QAAQ,mBAAmB;AAC/C,QAAM,eAAe,QAAQ,gBAAgB;AAE7C,QAAM,SAAS,MAAM,aAAa;AAKlC,SAAO,OAAO,QAAwC;AACpD,UAAM,YAAa,OAAO,CAAC;AAG3B,QACE,EACE,OAAO,UAAU,cAAc,YAC/B,YAAY,UAAU,SAAS,IAEjC;AACA,aAAO,EAAE,UAAU,QAAQ;AAAA,IAC7B;AAGA,UAAM,eAAe,uBAAuB,SAAS;AACrD,UAAM,cAAc,uBAAuB,SAAS;AAEpD,QAAI,gBAAgB,CAAC,OAAO,sBAAsB,SAAS,YAAY,GAAG;AACxE,aAAO,EAAE,UAAU,QAAQ,QAAQ,2BAA2B;AAAA,IAChE;AAEA,QAAI,gBAAgB,MAAM;AACxB,aAAO,EAAE,UAAU,QAAQ,QAAQ,sBAAsB;AAAA,IAC3D;AAEA,UAAM,aAAa,WAAW,OAAO,mBAAmB;AACxD,UAAM,YAAY,WAAW,OAAO,oBAAoB;AAExD,QAAI,cAAc,YAAY;AAC5B,aAAO,EAAE,UAAU,QAAQ,QAAQ,yBAAyB;AAAA,IAC9D;AAEA,QAAI,eAAe,WAAW;AAC5B,aAAO,EAAE,UAAU,QAAQ;AAAA,IAC7B;AAIA,WAAO,EAAE,UAAU,MAAM;AAAA,EAC3B;AACF;;;AF5KA,eAAsB,aAA4B;AAChD,QAAM,OAAO,MAAM,qBAAqB;AAExC,MAAI,MAAM;AACV,mBAAiB,SAAS,QAAQ,OAA2C;AAC3E,WAAO,MAAM,SAAS,OAAO;AAAA,EAC/B;AAEA,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,KAAK,EAAE,SAAS,IAAI,KAAK,MAAM,GAAG,IAAI,CAAC;AAAA,EACtD,SAAS,KAAK;AACZ,YAAQ,OAAO;AAAA,MACb,oDAAqD,IAAc,OAAO;AAAA;AAAA,IAC5E;AACA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,WAAW,MAAM,KAAK,MAAM;AAElC,QAAM,SAAS;AAAA,IACb,oBAAoB;AAAA,MAClB,eAAe;AAAA,MACf,oBAAoB,SAAS;AAAA,MAC7B,GAAI,SAAS,SAAS,EAAE,0BAA0B,SAAS,OAAO,IAAI,CAAC;AAAA,IACzE;AAAA,EACF;AACA,UAAQ,OAAO,MAAM,KAAK,UAAU,MAAM,CAAC;AAC3C,UAAQ,KAAK,SAAS,aAAa,SAAS,IAAI,CAAC;AACnD;","names":[]}
|
package/dist/hook-entrypoint.js
CHANGED
|
@@ -1,121 +1,3 @@
|
|
|
1
|
-
// src/hmac.ts
|
|
2
|
-
import { createHash, createHmac } from "crypto";
|
|
3
|
-
function computeSignature(secret, method, path, subOrgId, body, timestamp) {
|
|
4
|
-
const bodyDigest = createHash("sha256").update(body).digest("hex");
|
|
5
|
-
const signingString = `${method}
|
|
6
|
-
${path}
|
|
7
|
-
${subOrgId}
|
|
8
|
-
${bodyDigest}
|
|
9
|
-
${timestamp}`;
|
|
10
|
-
return createHmac("sha256", secret).update(signingString).digest("hex");
|
|
11
|
-
}
|
|
12
|
-
function buildHmacHeaders(secret, method, path, subOrgId, body) {
|
|
13
|
-
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
14
|
-
const signature = computeSignature(
|
|
15
|
-
secret,
|
|
16
|
-
method,
|
|
17
|
-
path,
|
|
18
|
-
subOrgId,
|
|
19
|
-
body,
|
|
20
|
-
timestamp
|
|
21
|
-
);
|
|
22
|
-
return {
|
|
23
|
-
"X-KH-Sub-Org": subOrgId,
|
|
24
|
-
"X-KH-Timestamp": timestamp,
|
|
25
|
-
"X-KH-Signature": signature
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// src/types.ts
|
|
30
|
-
var KeeperHubError = class extends Error {
|
|
31
|
-
code;
|
|
32
|
-
constructor(code, message) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.name = "KeeperHubError";
|
|
35
|
-
this.code = code;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
var WalletConfigMissingError = class extends Error {
|
|
39
|
-
constructor() {
|
|
40
|
-
super(
|
|
41
|
-
"Wallet config not found at ~/.keeperhub/wallet.json. Run `npx @keeperhub/wallet add` to provision."
|
|
42
|
-
);
|
|
43
|
-
this.name = "WalletConfigMissingError";
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// src/client.ts
|
|
48
|
-
var TRAILING_SLASH = /\/$/;
|
|
49
|
-
function defaultCodeForStatus(status) {
|
|
50
|
-
if (status === 401) {
|
|
51
|
-
return "HMAC_INVALID";
|
|
52
|
-
}
|
|
53
|
-
if (status === 403) {
|
|
54
|
-
return "POLICY_BLOCKED";
|
|
55
|
-
}
|
|
56
|
-
if (status === 404) {
|
|
57
|
-
return "NOT_FOUND";
|
|
58
|
-
}
|
|
59
|
-
if (status === 502) {
|
|
60
|
-
return "TURNKEY_UPSTREAM";
|
|
61
|
-
}
|
|
62
|
-
return `HTTP_${status}`;
|
|
63
|
-
}
|
|
64
|
-
var KeeperHubClient = class {
|
|
65
|
-
baseUrl;
|
|
66
|
-
fetchImpl;
|
|
67
|
-
wallet;
|
|
68
|
-
constructor(wallet, opts = {}) {
|
|
69
|
-
this.wallet = wallet;
|
|
70
|
-
const envBase = process.env.KEEPERHUB_API_URL;
|
|
71
|
-
this.baseUrl = (opts.baseUrl ?? envBase ?? "https://app.keeperhub.com").replace(TRAILING_SLASH, "");
|
|
72
|
-
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* HMAC-signed POST/GET to any /api/agentic-wallet/* route except
|
|
76
|
-
* /provision. Path MUST start with a leading slash. Body is
|
|
77
|
-
* JSON.stringify'd (or the empty string for GET).
|
|
78
|
-
*
|
|
79
|
-
* Error mapping: non-2xx/non-202 surface as `KeeperHubError(code,
|
|
80
|
-
* message)` where `code` is the server-supplied field or the default
|
|
81
|
-
* taxonomy (`HMAC_INVALID`, `POLICY_BLOCKED`, `NOT_FOUND`,
|
|
82
|
-
* `TURNKEY_UPSTREAM`, `HTTP_<status>`). 202 ask-tier surfaces as an
|
|
83
|
-
* AskTierResponse envelope.
|
|
84
|
-
*/
|
|
85
|
-
async request(method, path, body) {
|
|
86
|
-
const bodyStr = body === void 0 ? "" : JSON.stringify(body);
|
|
87
|
-
const hmacHeaders = buildHmacHeaders(
|
|
88
|
-
this.wallet.hmacSecret,
|
|
89
|
-
method,
|
|
90
|
-
path,
|
|
91
|
-
this.wallet.subOrgId,
|
|
92
|
-
bodyStr
|
|
93
|
-
);
|
|
94
|
-
const headers = method === "POST" ? { ...hmacHeaders, "content-type": "application/json" } : { ...hmacHeaders };
|
|
95
|
-
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
96
|
-
method,
|
|
97
|
-
headers,
|
|
98
|
-
body: method === "POST" ? bodyStr : void 0
|
|
99
|
-
});
|
|
100
|
-
if (response.status === 202) {
|
|
101
|
-
const data = await response.json();
|
|
102
|
-
return { _status: 202, approvalRequestId: data.approvalRequestId };
|
|
103
|
-
}
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
let code = "UNKNOWN";
|
|
106
|
-
let message = `HTTP ${response.status}`;
|
|
107
|
-
try {
|
|
108
|
-
const data = await response.json();
|
|
109
|
-
code = data.code ?? defaultCodeForStatus(response.status);
|
|
110
|
-
message = data.error ?? message;
|
|
111
|
-
} catch {
|
|
112
|
-
}
|
|
113
|
-
throw new KeeperHubError(code, message);
|
|
114
|
-
}
|
|
115
|
-
return await response.json();
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
1
|
// src/safety-config.ts
|
|
120
2
|
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
|
|
121
3
|
import { homedir } from "os";
|
|
@@ -192,31 +74,7 @@ function validateAndMerge(partial) {
|
|
|
192
74
|
return merged;
|
|
193
75
|
}
|
|
194
76
|
|
|
195
|
-
// src/storage.ts
|
|
196
|
-
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
197
|
-
import { homedir as homedir2 } from "os";
|
|
198
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
199
|
-
async function readWalletConfig() {
|
|
200
|
-
const walletPath = join2(homedir2(), ".keeperhub", "wallet.json");
|
|
201
|
-
let raw;
|
|
202
|
-
try {
|
|
203
|
-
raw = await readFile2(walletPath, "utf-8");
|
|
204
|
-
} catch (err) {
|
|
205
|
-
if (err.code === "ENOENT") {
|
|
206
|
-
throw new WalletConfigMissingError();
|
|
207
|
-
}
|
|
208
|
-
throw err;
|
|
209
|
-
}
|
|
210
|
-
const parsed = JSON.parse(raw);
|
|
211
|
-
if (!(parsed.subOrgId && parsed.walletAddress && parsed.hmacSecret)) {
|
|
212
|
-
throw new Error(`Malformed wallet.json at ${walletPath}`);
|
|
213
|
-
}
|
|
214
|
-
return parsed;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
77
|
// src/hook.ts
|
|
218
|
-
var DEFAULT_POLL = { intervalMs: 2e3, maxAttempts: 150 };
|
|
219
|
-
var APPROVAL_URL_BASE = "https://app.keeperhub.com/approve/";
|
|
220
78
|
var USDC_DECIMALS = 1e6;
|
|
221
79
|
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
222
80
|
var MICRO_USDC_RE = /^\d+$/;
|
|
@@ -267,16 +125,6 @@ function usdToMicro(usd) {
|
|
|
267
125
|
async function createPreToolUseHook(options = {}) {
|
|
268
126
|
const toolMatcher = options.toolNameMatcher ?? defaultToolMatcher;
|
|
269
127
|
const configLoader = options.configLoader ?? loadSafetyConfig;
|
|
270
|
-
const walletLoader = options.walletLoader ?? readWalletConfig;
|
|
271
|
-
const clientFactory = options.clientFactory ?? ((w) => new KeeperHubClient(w));
|
|
272
|
-
const onAskOpen = options.onAskOpen ?? ((url) => {
|
|
273
|
-
process.stderr.write(
|
|
274
|
-
`
|
|
275
|
-
[keeperhub-wallet] Approval required. Visit: ${url}
|
|
276
|
-
`
|
|
277
|
-
);
|
|
278
|
-
});
|
|
279
|
-
const poll = options.poll ?? DEFAULT_POLL;
|
|
280
128
|
const safety = await configLoader();
|
|
281
129
|
return async (raw) => {
|
|
282
130
|
const hookInput = raw ?? {};
|
|
@@ -292,43 +140,10 @@ async function createPreToolUseHook(options = {}) {
|
|
|
292
140
|
return { decision: "deny", reason: "AMOUNT_UNDETERMINED" };
|
|
293
141
|
}
|
|
294
142
|
const blockMicro = usdToMicro(safety.block_threshold_usd);
|
|
295
|
-
const askMicro = usdToMicro(safety.ask_threshold_usd);
|
|
296
143
|
const autoMicro = usdToMicro(safety.auto_approve_max_usd);
|
|
297
144
|
if (amountMicro > blockMicro) {
|
|
298
145
|
return { decision: "deny", reason: "BLOCKED_BY_SAFETY_RULE" };
|
|
299
146
|
}
|
|
300
|
-
if (amountMicro >= askMicro) {
|
|
301
|
-
const wallet = await walletLoader();
|
|
302
|
-
const client = clientFactory(wallet);
|
|
303
|
-
const created = await client.request("POST", "/api/agentic-wallet/approval-request", {
|
|
304
|
-
// Server contract (Phase 33): riskLevel MUST be 'ask' or 'block';
|
|
305
|
-
// operationPayload MUST be a non-array object. The hook only creates
|
|
306
|
-
// approval-requests at the ask tier (block tier short-circuits above).
|
|
307
|
-
riskLevel: "ask",
|
|
308
|
-
operationPayload: {
|
|
309
|
-
amountMicroUsdc: amountMicro.toString(),
|
|
310
|
-
contractAddress: contractAddr ?? "",
|
|
311
|
-
toolName: hookInput.tool_name ?? "",
|
|
312
|
-
reason: `Agent tool ${hookInput.tool_name}`
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
const approvalId = "_status" in created ? created.approvalRequestId : created.id;
|
|
316
|
-
onAskOpen(`${APPROVAL_URL_BASE}${approvalId}`);
|
|
317
|
-
for (let attempt = 0; attempt < poll.maxAttempts; attempt++) {
|
|
318
|
-
await new Promise((r) => setTimeout(r, poll.intervalMs));
|
|
319
|
-
const status = await client.request("GET", `/api/agentic-wallet/approval-request/${approvalId}`);
|
|
320
|
-
if (!("status" in status)) {
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
if (status.status === "approved") {
|
|
324
|
-
return { decision: "allow" };
|
|
325
|
-
}
|
|
326
|
-
if (status.status === "rejected") {
|
|
327
|
-
return { decision: "deny", reason: "USER_REJECTED" };
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return { decision: "deny", reason: "APPROVAL_TIMEOUT" };
|
|
331
|
-
}
|
|
332
147
|
if (amountMicro <= autoMicro) {
|
|
333
148
|
return { decision: "allow" };
|
|
334
149
|
}
|