@xpr-agents/openclaw 0.8.1 → 0.8.3

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.
@@ -247,6 +247,23 @@ function parsePrice(price) {
247
247
  const contract = getTokenContract(symbol);
248
248
  return { amount, symbol, precision, contract };
249
249
  }
250
+ // ── Transfer cap ─────────────────────────────────
251
+ // SECURITY: the runner enforces maxTransferAmount on the CORE escrow/agent tools,
252
+ // but skills sign their own transactions, so a prompt-injected NFT purchase/bid
253
+ // could spend far more than the operator's cap. Enforce the same ceiling here on
254
+ // any XPR the agent SENDS. Cap is XPR-denominated via MAX_TRANSFER_XPR (default
255
+ // 1000, matching the core default); set it higher to allow larger trades.
256
+ function assertXprWithinCap(parsed, label) {
257
+ if (parsed.symbol !== 'XPR')
258
+ return; // cap is XPR-denominated
259
+ const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
260
+ if (!Number.isFinite(capXpr) || capXpr <= 0)
261
+ return; // disabled/invalid -> no cap
262
+ const amt = Number(parsed.amount);
263
+ if (Number.isFinite(amt) && amt > capXpr) {
264
+ throw new Error(`${label}: ${parsed.amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
265
+ }
266
+ }
250
267
  // ── Validation Helpers ───────────────────────────
251
268
  function isValidEosioName(name) {
252
269
  if (!name || name.length > 12)
@@ -1301,6 +1318,7 @@ function nftSkill(api) {
1301
1318
  return { error: 'price is required (must match listing price exactly)' };
1302
1319
  try {
1303
1320
  const parsed = parsePrice(price);
1321
+ assertXprWithinCap(parsed, 'nft_purchase');
1304
1322
  const session = await getNftSession();
1305
1323
  await ensureRam(session);
1306
1324
  const result = await session.api.transact({
@@ -1427,6 +1445,7 @@ function nftSkill(api) {
1427
1445
  return { error: 'bid_amount is required (e.g. "50.0000 XPR")' };
1428
1446
  try {
1429
1447
  const parsed = parsePrice(bid_amount);
1448
+ assertXprWithinCap(parsed, 'nft_bid');
1430
1449
  const session = await getNftSession();
1431
1450
  await ensureRam(session);
1432
1451
  const result = await session.api.transact({
@@ -248,6 +248,22 @@ function parsePrice(price: string): { amount: string; symbol: string; precision:
248
248
  return { amount, symbol, precision, contract };
249
249
  }
250
250
 
251
+ // ── Transfer cap ─────────────────────────────────
252
+ // SECURITY: the runner enforces maxTransferAmount on the CORE escrow/agent tools,
253
+ // but skills sign their own transactions, so a prompt-injected NFT purchase/bid
254
+ // could spend far more than the operator's cap. Enforce the same ceiling here on
255
+ // any XPR the agent SENDS. Cap is XPR-denominated via MAX_TRANSFER_XPR (default
256
+ // 1000, matching the core default); set it higher to allow larger trades.
257
+ function assertXprWithinCap(parsed: { amount: string; symbol: string }, label: string): void {
258
+ if (parsed.symbol !== 'XPR') return; // cap is XPR-denominated
259
+ const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
260
+ if (!Number.isFinite(capXpr) || capXpr <= 0) return; // disabled/invalid -> no cap
261
+ const amt = Number(parsed.amount);
262
+ if (Number.isFinite(amt) && amt > capXpr) {
263
+ throw new Error(`${label}: ${parsed.amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
264
+ }
265
+ }
266
+
251
267
  // ── Validation Helpers ───────────────────────────
252
268
 
253
269
  function isValidEosioName(name: string): boolean {
@@ -1313,6 +1329,7 @@ export default function nftSkill(api: SkillApi): void {
1313
1329
 
1314
1330
  try {
1315
1331
  const parsed = parsePrice(price);
1332
+ assertXprWithinCap(parsed, 'nft_purchase');
1316
1333
  const session = await getNftSession();
1317
1334
  await ensureRam(session);
1318
1335
 
@@ -1443,6 +1460,7 @@ export default function nftSkill(api: SkillApi): void {
1443
1460
 
1444
1461
  try {
1445
1462
  const parsed = parsePrice(bid_amount);
1463
+ assertXprWithinCap(parsed, 'nft_bid');
1446
1464
  const session = await getNftSession();
1447
1465
  await ensureRam(session);
1448
1466
 
@@ -113,12 +113,12 @@ These tools replace `@proton/cli` commands for environments where the CLI isn't
113
113
  ### Resources
114
114
 
115
115
  **RPC Endpoints:**
116
- - Mainnet: `https://proton.eosusa.io`
117
- - Testnet: `https://proton-testnet.eosusa.io`
116
+ - Mainnet: `https://api-xprnetwork-main.saltant.io` (alt: `https://proton.protonuk.io`)
117
+ - Testnet: `https://api-xprnetwork-test.saltant.io`
118
118
 
119
119
  **Chain IDs:**
120
120
  - Mainnet: `384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0`
121
- - Testnet: `71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abebd3e7df`
121
+ - Testnet: `71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd`
122
122
 
123
123
  **Key Packages:**
124
124
  - `proton-tsc` — AssemblyScript contract SDK (types, decorators, utilities)
@@ -50,11 +50,12 @@ async function rpcGet(endpoint, path) {
50
50
  // ── Hyperion endpoint resolution ────────────────
51
51
  function getHyperionEndpoint(rpcEndpoint) {
52
52
  // Map known RPC endpoints to their Hyperion equivalents
53
- if (rpcEndpoint.includes('proton-testnet') || rpcEndpoint.includes('proton-test')) {
54
- return 'https://proton-testnet.eosusa.io';
53
+ if (rpcEndpoint.includes('proton-testnet') || rpcEndpoint.includes('proton-test') || rpcEndpoint.includes('tn1.protonnz.com') || rpcEndpoint.includes('xprnetwork-test')) {
54
+ return 'https://api-xprnetwork-test.saltant.io';
55
55
  }
56
- if (rpcEndpoint.includes('proton.eosusa.io') || rpcEndpoint.includes('proton.greymass.com')) {
57
- return 'https://proton.eosusa.io';
56
+ // nodeos-only public RPCs (no /v2 history) map to a Hyperion-serving producer endpoint
57
+ if (/proton\.eosusa\.io|proton\.greymass\.com|api\.protonnz\.com|proton\.cryptolions\.io/.test(rpcEndpoint)) {
58
+ return 'https://api-xprnetwork-main.saltant.io';
58
59
  }
59
60
  // Default: assume the RPC endpoint also serves Hyperion
60
61
  return rpcEndpoint;
@@ -65,11 +65,12 @@ async function rpcGet(endpoint: string, path: string): Promise<any> {
65
65
 
66
66
  function getHyperionEndpoint(rpcEndpoint: string): string {
67
67
  // Map known RPC endpoints to their Hyperion equivalents
68
- if (rpcEndpoint.includes('proton-testnet') || rpcEndpoint.includes('proton-test')) {
69
- return 'https://proton-testnet.eosusa.io';
68
+ if (rpcEndpoint.includes('proton-testnet') || rpcEndpoint.includes('proton-test') || rpcEndpoint.includes('tn1.protonnz.com') || rpcEndpoint.includes('xprnetwork-test')) {
69
+ return 'https://api-xprnetwork-test.saltant.io';
70
70
  }
71
- if (rpcEndpoint.includes('proton.eosusa.io') || rpcEndpoint.includes('proton.greymass.com')) {
72
- return 'https://proton.eosusa.io';
71
+ // nodeos-only public RPCs (no /v2 history) map to a Hyperion-serving producer endpoint
72
+ if (/proton\.eosusa\.io|proton\.greymass\.com|api\.protonnz\.com|proton\.cryptolions\.io/.test(rpcEndpoint)) {
73
+ return 'https://api-xprnetwork-main.saltant.io';
73
74
  }
74
75
  // Default: assume the RPC endpoint also serves Hyperion
75
76
  return rpcEndpoint;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.default = webScrapingSkill;
9
+ const ssrf_1 = require("./ssrf");
9
10
  // ── Constants ───────────────────────────────────
10
11
  const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB
11
12
  const DEFAULT_TIMEOUT = 30000;
@@ -15,9 +16,10 @@ async function fetchPage(url, timeout = DEFAULT_TIMEOUT, headers) {
15
16
  const controller = new AbortController();
16
17
  const timer = setTimeout(() => controller.abort(), timeout);
17
18
  try {
18
- const resp = await fetch(url, {
19
+ // guardedFetch resolves the host and refuses private/internal targets, and
20
+ // re-validates every redirect hop (SSRF: the URL is agent/job-controlled).
21
+ const resp = await (0, ssrf_1.guardedFetch)(url, {
19
22
  signal: controller.signal,
20
- redirect: 'follow',
21
23
  headers: {
22
24
  'User-Agent': USER_AGENT,
23
25
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPrivateAddress = isPrivateAddress;
4
+ exports.assertPublicUrl = assertPublicUrl;
5
+ exports.guardedFetch = guardedFetch;
6
+ /**
7
+ * SSRF guard for skill fetches.
8
+ *
9
+ * These fetches are steered by agent/job-controlled URLs, and the runner sits
10
+ * inside a private network with cloud metadata and internal services reachable.
11
+ * Resolve the host and refuse any loopback / private / link-local / ULA / CGNAT /
12
+ * metadata address, and follow redirects MANUALLY so a public host that
13
+ * 3xx-redirects to an internal one is re-validated at every hop.
14
+ */
15
+ const promises_1 = require("node:dns/promises");
16
+ const node_net_1 = require("node:net");
17
+ /** True for loopback, private, link-local, ULA, CGNAT, multicast and unparseable addresses. */
18
+ function isPrivateAddress(ip) {
19
+ const v = (0, node_net_1.isIP)(ip);
20
+ if (v === 4) {
21
+ const p = ip.split('.').map(Number);
22
+ if (p.length !== 4 || p.some(n => Number.isNaN(n)))
23
+ return true;
24
+ const [a, b, c] = p;
25
+ return (a === 0 || a === 10 || a === 127 ||
26
+ (a === 169 && b === 254) || // link-local + cloud metadata
27
+ (a === 172 && b >= 16 && b <= 31) ||
28
+ (a === 192 && b === 168) ||
29
+ (a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 (IETF protocol assignments)
30
+ (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 (benchmarking)
31
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT
32
+ a >= 224 // multicast / reserved
33
+ );
34
+ }
35
+ if (v === 6) {
36
+ const s = ip.toLowerCase();
37
+ return (s === '::1' || s === '::' ||
38
+ s.startsWith('::ffff:') || // IPv4-mapped
39
+ s.startsWith('64:ff9b') || // NAT64 well-known prefix
40
+ /^fe[89a-f]/.test(s) || // link-local fe80::/10 + site-local fec0::/10
41
+ s.startsWith('fc') || s.startsWith('fd') // unique-local
42
+ );
43
+ }
44
+ return true;
45
+ }
46
+ /** Resolve a URL's host and throw unless every address it maps to is public http(s). */
47
+ async function assertPublicUrl(urlStr) {
48
+ let host;
49
+ try {
50
+ const u = new URL(urlStr);
51
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
52
+ throw new Error(`Blocked non-http(s) URL: ${u.protocol}`);
53
+ }
54
+ host = u.hostname;
55
+ }
56
+ catch (e) {
57
+ throw new Error(`Blocked invalid URL: ${e.message}`);
58
+ }
59
+ if ((0, node_net_1.isIP)(host)) {
60
+ if (isPrivateAddress(host))
61
+ throw new Error(`Blocked private address: ${host}`);
62
+ return;
63
+ }
64
+ let addrs;
65
+ try {
66
+ addrs = await (0, promises_1.lookup)(host, { all: true });
67
+ }
68
+ catch {
69
+ throw new Error(`Blocked host that does not resolve: ${host}`);
70
+ }
71
+ if (addrs.length === 0 || addrs.some(a => isPrivateAddress(a.address))) {
72
+ throw new Error(`Blocked host resolving to a private address: ${host}`);
73
+ }
74
+ }
75
+ /**
76
+ * fetch() with an SSRF guard on every hop. Redirects are followed manually (up to
77
+ * maxRedirects) so each Location is re-validated — a public host cannot 302 to an
78
+ * internal one. Throws if a hop targets a private address or the redirect chain is
79
+ * too long.
80
+ */
81
+ async function guardedFetch(url, init = {}, maxRedirects = 3) {
82
+ let current = url;
83
+ for (let i = 0; i <= maxRedirects; i++) {
84
+ await assertPublicUrl(current);
85
+ const resp = await fetch(current, { ...init, redirect: 'manual' });
86
+ if (resp.status >= 300 && resp.status < 400) {
87
+ const loc = resp.headers.get('location');
88
+ if (!loc)
89
+ return resp;
90
+ current = new URL(loc, current).toString();
91
+ continue;
92
+ }
93
+ return resp;
94
+ }
95
+ throw new Error(`Too many redirects (>${maxRedirects})`);
96
+ }
@@ -4,6 +4,8 @@
4
4
  * Zero external dependencies — uses Node.js built-in fetch and regex-based HTML parsing.
5
5
  */
6
6
 
7
+ import { guardedFetch } from './ssrf';
8
+
7
9
  interface ToolDef {
8
10
  name: string;
9
11
  description: string;
@@ -33,9 +35,10 @@ async function fetchPage(
33
35
  const timer = setTimeout(() => controller.abort(), timeout);
34
36
 
35
37
  try {
36
- const resp = await fetch(url, {
38
+ // guardedFetch resolves the host and refuses private/internal targets, and
39
+ // re-validates every redirect hop (SSRF: the URL is agent/job-controlled).
40
+ const resp = await guardedFetch(url, {
37
41
  signal: controller.signal,
38
- redirect: 'follow',
39
42
  headers: {
40
43
  'User-Agent': USER_AGENT,
41
44
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -0,0 +1,91 @@
1
+ /**
2
+ * SSRF guard for skill fetches.
3
+ *
4
+ * These fetches are steered by agent/job-controlled URLs, and the runner sits
5
+ * inside a private network with cloud metadata and internal services reachable.
6
+ * Resolve the host and refuse any loopback / private / link-local / ULA / CGNAT /
7
+ * metadata address, and follow redirects MANUALLY so a public host that
8
+ * 3xx-redirects to an internal one is re-validated at every hop.
9
+ */
10
+ import { lookup } from 'node:dns/promises';
11
+ import { isIP } from 'node:net';
12
+
13
+ /** True for loopback, private, link-local, ULA, CGNAT, multicast and unparseable addresses. */
14
+ export function isPrivateAddress(ip: string): boolean {
15
+ const v = isIP(ip);
16
+ if (v === 4) {
17
+ const p = ip.split('.').map(Number);
18
+ if (p.length !== 4 || p.some(n => Number.isNaN(n))) return true;
19
+ const [a, b, c] = p;
20
+ return (
21
+ a === 0 || a === 10 || a === 127 ||
22
+ (a === 169 && b === 254) || // link-local + cloud metadata
23
+ (a === 172 && b >= 16 && b <= 31) ||
24
+ (a === 192 && b === 168) ||
25
+ (a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 (IETF protocol assignments)
26
+ (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 (benchmarking)
27
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT
28
+ a >= 224 // multicast / reserved
29
+ );
30
+ }
31
+ if (v === 6) {
32
+ const s = ip.toLowerCase();
33
+ return (
34
+ s === '::1' || s === '::' ||
35
+ s.startsWith('::ffff:') || // IPv4-mapped
36
+ s.startsWith('64:ff9b') || // NAT64 well-known prefix
37
+ /^fe[89a-f]/.test(s) || // link-local fe80::/10 + site-local fec0::/10
38
+ s.startsWith('fc') || s.startsWith('fd') // unique-local
39
+ );
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /** Resolve a URL's host and throw unless every address it maps to is public http(s). */
45
+ export async function assertPublicUrl(urlStr: string): Promise<void> {
46
+ let host: string;
47
+ try {
48
+ const u = new URL(urlStr);
49
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
50
+ throw new Error(`Blocked non-http(s) URL: ${u.protocol}`);
51
+ }
52
+ host = u.hostname;
53
+ } catch (e) {
54
+ throw new Error(`Blocked invalid URL: ${(e as Error).message}`);
55
+ }
56
+ if (isIP(host)) {
57
+ if (isPrivateAddress(host)) throw new Error(`Blocked private address: ${host}`);
58
+ return;
59
+ }
60
+ let addrs: { address: string }[];
61
+ try {
62
+ addrs = await lookup(host, { all: true });
63
+ } catch {
64
+ throw new Error(`Blocked host that does not resolve: ${host}`);
65
+ }
66
+ if (addrs.length === 0 || addrs.some(a => isPrivateAddress(a.address))) {
67
+ throw new Error(`Blocked host resolving to a private address: ${host}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * fetch() with an SSRF guard on every hop. Redirects are followed manually (up to
73
+ * maxRedirects) so each Location is re-validated — a public host cannot 302 to an
74
+ * internal one. Throws if a hop targets a private address or the redirect chain is
75
+ * too long.
76
+ */
77
+ export async function guardedFetch(url: string, init: RequestInit = {}, maxRedirects = 3): Promise<Response> {
78
+ let current = url;
79
+ for (let i = 0; i <= maxRedirects; i++) {
80
+ await assertPublicUrl(current);
81
+ const resp = await fetch(current, { ...init, redirect: 'manual' });
82
+ if (resp.status >= 300 && resp.status < 400) {
83
+ const loc = resp.headers.get('location');
84
+ if (!loc) return resp;
85
+ current = new URL(loc, current).toString();
86
+ continue;
87
+ }
88
+ return resp;
89
+ }
90
+ throw new Error(`Too many redirects (>${maxRedirects})`);
91
+ }
@@ -5,7 +5,7 @@
5
5
  * Usage: node test-read.mjs
6
6
  */
7
7
 
8
- const RPC = 'https://proton.eosusa.io';
8
+ const RPC = 'https://api.protonnz.com';
9
9
  const XMD_TOKEN = 'xmd.token';
10
10
  const XMD_TREASURY = 'xmd.treasury';
11
11
  const ORACLE = 'oracles';