@xpr-agents/openclaw 0.8.1 → 0.8.2

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.
@@ -25,61 +25,79 @@ const MAX_TIMEOUT = 30000;
25
25
  const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
26
26
 
27
27
  // ── Sandbox helpers ─────────────────────────────
28
+ //
29
+ // SECURITY: the context is given ONLY primitive strings — never a host function or
30
+ // object. That is the whole game with node:vm. `codeGeneration.strings:false` only
31
+ // disables eval/Function *for this context's own realm*; a host closure handed in
32
+ // (a console mock, atob/btoa via Buffer, anything) exposes `fn.constructor` — the
33
+ // HOST realm's Function, where code generation is still allowed — so
34
+ // `console.log.constructor("return process.env")()` would read the runner's secrets
35
+ // and `Object.getPrototypeOf(hostFn).constructor.prototype` would pollute the host.
36
+ // So: console, atob/btoa, INPUT parsing and the *result serialization* all run as
37
+ // sandbox-realm code (below). Serializing inside the sandbox also keeps it under the
38
+ // execution timeout — a malicious getter can no longer stall the host via a
39
+ // host-side JSON.stringify. The only value read back out is a JSON string (a
40
+ // primitive), which the host then parses safely.
41
+
42
+ /** Sandbox-realm preamble: pure-JS console/atob/btoa/INPUT, no host references. */
43
+ const SANDBOX_PREAMBLE = `
44
+ var __logs = [];
45
+ var console = {
46
+ log: function(){ __logs.push(Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
47
+ warn: function(){ __logs.push('[warn] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
48
+ error: function(){ __logs.push('[error] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); }
49
+ };
50
+ var __B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
51
+ function btoa(s){ s = String(s); var o = ''; for (var i = 0; i < s.length; ) {
52
+ var c1 = s.charCodeAt(i++), c2 = s.charCodeAt(i++), c3 = s.charCodeAt(i++);
53
+ var e1 = c1 >> 2, e2 = ((c1 & 3) << 4) | (c2 >> 4), e3 = ((c2 & 15) << 2) | (c3 >> 6), e4 = c3 & 63;
54
+ if (isNaN(c2)) { e3 = e4 = 64; } else if (isNaN(c3)) { e4 = 64; }
55
+ o += __B64.charAt(e1) + __B64.charAt(e2) + (e3 === 64 ? '=' : __B64.charAt(e3)) + (e4 === 64 ? '=' : __B64.charAt(e4));
56
+ } return o; }
57
+ function atob(s){ s = String(s).replace(/[^A-Za-z0-9+/=]/g, ''); var o = ''; for (var i = 0; i < s.length; ) {
58
+ var d1 = __B64.indexOf(s.charAt(i++)), d2 = __B64.indexOf(s.charAt(i++)), d3 = __B64.indexOf(s.charAt(i++)), d4 = __B64.indexOf(s.charAt(i++));
59
+ var c1 = (d1 << 2) | (d2 >> 4), c2 = ((d2 & 15) << 4) | (d3 >> 2), c3 = ((d3 & 3) << 6) | d4;
60
+ o += String.fromCharCode(c1); if (d3 !== 64 && d3 >= 0) o += String.fromCharCode(c2); if (d4 !== 64 && d4 >= 0) o += String.fromCharCode(c3);
61
+ } return o; }
62
+ `.trim();
28
63
 
29
- function createSandboxGlobals(input: unknown, logs: string[]): Record<string, unknown> {
30
- // Capture console methods
31
- const consoleMock = {
32
- log: (...args: unknown[]) => {
33
- logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
34
- },
35
- warn: (...args: unknown[]) => {
36
- logs.push('[warn] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
37
- },
38
- error: (...args: unknown[]) => {
39
- logs.push('[error] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
40
- },
41
- };
42
-
43
- return {
44
- INPUT: input,
45
- console: consoleMock,
46
- JSON,
47
- Math,
48
- Date,
49
- Array,
50
- Object,
51
- String,
52
- Number,
53
- RegExp,
54
- Map,
55
- Set,
56
- parseInt,
57
- parseFloat,
58
- isNaN,
59
- isFinite,
60
- encodeURIComponent,
61
- decodeURIComponent,
62
- atob: (s: string) => Buffer.from(s, 'base64').toString('binary'),
63
- btoa: (s: string) => Buffer.from(s, 'binary').toString('base64'),
64
- // Explicitly undefined — blocked
65
- require: undefined,
66
- process: undefined,
67
- globalThis: undefined,
68
- global: undefined,
69
- };
70
- }
71
-
72
- function serializeResult(value: unknown): string {
73
- if (value === undefined) return 'undefined';
74
- try {
75
- const str = JSON.stringify(value, null, 2);
76
- if (str.length > MAX_OUTPUT_SIZE) {
77
- return str.slice(0, MAX_OUTPUT_SIZE) + '\n... [truncated at 10MB]';
78
- }
79
- return str;
80
- } catch {
81
- return String(value);
82
- }
64
+ /**
65
+ * Run a self-contained expression/body in a fresh vm realm and return the parsed
66
+ * outcome. `body` must be a statement list whose LAST expression is the user value
67
+ * to capture. Everything is serialized to a JSON string inside the sandbox.
68
+ */
69
+ function runInSandbox(
70
+ body: string,
71
+ timeoutMs: number,
72
+ inputJson: string | undefined,
73
+ ): { ok: boolean; result?: unknown; logs: string[]; error?: string; oversized?: boolean } {
74
+ // Only a primitive string crosses into the realm — AND the context global is
75
+ // given a NULL prototype. If we hand vm.createContext an ordinary host object,
76
+ // the sandbox global inherits the HOST realm's Object.prototype, so
77
+ // `this.constructor.constructor("return process.env")()` walks to the host
78
+ // Function (where codeGeneration is allowed) and reads the runner's secrets —
79
+ // codeGeneration:false only covers THIS context's realm. A null-proto global
80
+ // makes `this.constructor` resolve to the sandbox realm's own Function, which
81
+ // the flag then blocks. (Confirmed escape via the global-object path, 2026-09-19.)
82
+ const sandboxGlobal: Record<string, unknown> = Object.create(null);
83
+ sandboxGlobal.__INPUT_JSON = inputJson;
84
+ const context = vm.createContext(sandboxGlobal, {
85
+ codeGeneration: { strings: false, wasm: false },
86
+ });
87
+ const wrapped = `${SANDBOX_PREAMBLE}
88
+ var INPUT = (typeof __INPUT_JSON === 'string') ? JSON.parse(__INPUT_JSON) : undefined;
89
+ var __result, __error = null;
90
+ try { __result = (function(){ ${body} \n})(); } catch (e) { __error = (e && e.message) ? String(e.message) : String(e); }
91
+ (function(){
92
+ try { return JSON.stringify({ ok: __error === null, result: __result === undefined ? null : __result, logs: __logs, error: __error }); }
93
+ catch (e) { return JSON.stringify({ ok: __error === null, result: String(__result), logs: __logs, error: __error }); }
94
+ })();`;
95
+ const script = new vm.Script(wrapped, { filename: 'sandbox.js' });
96
+ const out = script.runInContext(context, { timeout: timeoutMs }) as string;
97
+ if (typeof out !== 'string') return { ok: false, logs: [], error: 'sandbox produced no serializable output' };
98
+ if (out.length > MAX_OUTPUT_SIZE) return { ok: false, logs: [], error: 'Output exceeded 10MB limit', oversized: true };
99
+ const parsed = JSON.parse(out) as { ok: boolean; result?: unknown; logs: string[]; error?: string };
100
+ return parsed;
83
101
  }
84
102
 
85
103
  // ── Skill entry point ───────────────────────────
@@ -113,53 +131,41 @@ export default function codeSandboxSkill(api: SkillApi): void {
113
131
  }
114
132
 
115
133
  const timeoutMs = Math.min(Math.max(timeout || DEFAULT_TIMEOUT, 100), MAX_TIMEOUT);
116
- const logs: string[] = [];
117
134
  const startTime = Date.now();
118
135
 
136
+ let inputJson: string | undefined;
119
137
  try {
120
- const globals = createSandboxGlobals(input, logs);
121
- const context = vm.createContext(globals, {
122
- codeGeneration: { strings: false, wasm: false },
123
- });
124
-
125
- // Wrap code so the last expression is returned
126
- const wrapped = `(function() {\n${code}\n})()`;
127
- const script = new vm.Script(wrapped, { filename: 'sandbox.js' });
128
- const result = script.runInContext(context, { timeout: timeoutMs });
129
- const durationMs = Date.now() - startTime;
138
+ inputJson = input === undefined ? undefined : JSON.stringify(input);
139
+ } catch {
140
+ return { error: 'input could not be serialized to JSON' };
141
+ }
130
142
 
131
- const serialized = serializeResult(result);
132
- if (serialized.length > MAX_OUTPUT_SIZE) {
133
- return {
134
- result: serialized.slice(0, 1000) + '... [truncated]',
135
- logs,
136
- duration_ms: durationMs,
137
- warning: 'Output exceeded 10MB limit and was truncated',
138
- };
143
+ try {
144
+ const out = runInSandbox(code, timeoutMs, inputJson);
145
+ const durationMs = Date.now() - startTime;
146
+ if (out.oversized) {
147
+ return { error: 'Output exceeded 10MB limit', logs: out.logs, duration_ms: durationMs, warning: 'Output exceeded 10MB limit and was truncated' };
139
148
  }
140
-
141
- // Parse back to preserve types (arrays, objects)
142
- let parsed: unknown;
143
- try {
144
- parsed = JSON.parse(serialized);
145
- } catch {
146
- parsed = serialized === 'undefined' ? undefined : serialized;
149
+ if (!out.ok) {
150
+ const message = out.error || 'unknown error';
151
+ if (message.includes('Code generation from strings disallowed')) {
152
+ return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: out.logs, duration_ms: durationMs };
153
+ }
154
+ return { error: message, logs: out.logs, duration_ms: durationMs };
147
155
  }
148
-
149
- return { result: parsed, logs, duration_ms: durationMs };
156
+ return { result: out.result, logs: out.logs, duration_ms: durationMs };
150
157
  } catch (err: any) {
151
158
  const durationMs = Date.now() - startTime;
152
159
  const message = err.message || String(err);
153
160
 
154
- // Provide helpful error context
155
- if (message.includes('Script execution timed out')) {
156
- return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs, duration_ms: durationMs };
161
+ // Timeout is a hard interrupt thrown to the host, so it lands here.
162
+ if (message.includes('Script execution timed out') || message.includes('timed out')) {
163
+ return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs: [], duration_ms: durationMs };
157
164
  }
158
165
  if (message.includes('Code generation from strings disallowed')) {
159
- return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs, duration_ms: durationMs };
166
+ return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: [], duration_ms: durationMs };
160
167
  }
161
-
162
- return { error: message, logs, duration_ms: durationMs };
168
+ return { error: message, logs: [], duration_ms: durationMs };
163
169
  }
164
170
  },
165
171
  });
@@ -185,27 +191,17 @@ export default function codeSandboxSkill(api: SkillApi): void {
185
191
  }
186
192
 
187
193
  try {
188
- const globals = createSandboxGlobals(undefined, []);
189
- const context = vm.createContext(globals, {
190
- codeGeneration: { strings: false, wasm: false },
191
- });
192
-
193
- const script = new vm.Script(`(${expression})`, { filename: 'expr.js' });
194
- const result = script.runInContext(context, { timeout: DEFAULT_TIMEOUT });
195
-
196
- let serialized: unknown;
197
- try {
198
- serialized = JSON.parse(JSON.stringify(result));
199
- } catch {
200
- serialized = String(result);
201
- }
202
-
203
- return {
204
- result: serialized,
205
- type: result === null ? 'null' : Array.isArray(result) ? 'array' : typeof result,
206
- };
194
+ // Evaluate as the returned value of the sandbox body (same isolated realm,
195
+ // in-sandbox serialization). The value comes back as parsed JSON.
196
+ const out = runInSandbox(`return (${expression});`, DEFAULT_TIMEOUT, undefined);
197
+ if (!out.ok) return { error: out.error || 'evaluation failed' };
198
+ const r = out.result;
199
+ const type = r === null ? 'null' : Array.isArray(r) ? 'array' : typeof r;
200
+ return { result: r, type };
207
201
  } catch (err: any) {
208
- return { error: err.message || String(err) };
202
+ const message = err.message || String(err);
203
+ if (message.includes('timed out')) return { error: `Execution timed out after ${DEFAULT_TIMEOUT}ms.` };
204
+ return { error: message };
209
205
  }
210
206
  },
211
207
  });
@@ -8,6 +8,7 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.getDeliverable = getDeliverable;
10
10
  exports.default = creativeSkill;
11
+ const ssrf_1 = require("./ssrf");
11
12
  // ── Shared helpers ──────────────────────────────
12
13
  const MAX_DELIVERABLES = 200;
13
14
  const deliverables = new Map();
@@ -121,7 +122,9 @@ async function downloadFromUrl(url) {
121
122
  if (!/^https?:\/\//.test(url))
122
123
  return null;
123
124
  try {
124
- const resp = await fetch(url, { signal: AbortSignal.timeout(30000), redirect: 'follow' });
125
+ // SSRF: the URL is agent/job-controlled and this runs inside a private network.
126
+ // guardedFetch refuses private/internal targets and re-validates each redirect.
127
+ const resp = await (0, ssrf_1.guardedFetch)(url, { signal: AbortSignal.timeout(30000) });
125
128
  if (!resp.ok)
126
129
  return null;
127
130
  const contentType = resp.headers.get('content-type') || 'application/octet-stream';
@@ -156,7 +159,8 @@ function extractImages(text) {
156
159
  }
157
160
  async function downloadImage(url) {
158
161
  try {
159
- const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
162
+ // SSRF: image URLs come from agent-authored markdown — guard + re-validate redirects.
163
+ const resp = await (0, ssrf_1.guardedFetch)(url, { signal: AbortSignal.timeout(15000) });
160
164
  if (!resp.ok)
161
165
  return null;
162
166
  const ct = (resp.headers.get('content-type') || '').split(';')[0].trim();
@@ -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
+ }
@@ -5,6 +5,8 @@
5
5
  * Extracted from the main agent runner to validate the skill module format.
6
6
  */
7
7
 
8
+ import { guardedFetch } from './ssrf';
9
+
8
10
  interface ToolDef {
9
11
  name: string;
10
12
  description: string;
@@ -132,7 +134,9 @@ const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
132
134
  async function downloadFromUrl(url: string): Promise<{ buffer: Buffer; mimeType: string } | null> {
133
135
  if (!/^https?:\/\//.test(url)) return null;
134
136
  try {
135
- const resp = await fetch(url, { signal: AbortSignal.timeout(30000), redirect: 'follow' });
137
+ // SSRF: the URL is agent/job-controlled and this runs inside a private network.
138
+ // guardedFetch refuses private/internal targets and re-validates each redirect.
139
+ const resp = await guardedFetch(url, { signal: AbortSignal.timeout(30000) });
136
140
  if (!resp.ok) return null;
137
141
  const contentType = resp.headers.get('content-type') || 'application/octet-stream';
138
142
  const contentLength = parseInt(resp.headers.get('content-length') || '0');
@@ -161,7 +165,8 @@ function extractImages(text: string): { alt: string; url: string }[] {
161
165
 
162
166
  async function downloadImage(url: string): Promise<{ buffer: Buffer; type: string } | null> {
163
167
  try {
164
- const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
168
+ // SSRF: image URLs come from agent-authored markdown — guard + re-validate redirects.
169
+ const resp = await guardedFetch(url, { signal: AbortSignal.timeout(15000) });
165
170
  if (!resp.ok) return null;
166
171
  const ct = (resp.headers.get('content-type') || '').split(';')[0].trim();
167
172
  if (!ct.startsWith('image/')) return null;
@@ -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
+ }
@@ -176,6 +176,21 @@ function parseAssetString(s) {
176
176
  const precision = dotIdx >= 0 ? parts[0].length - dotIdx - 1 : 0;
177
177
  return { amount, symbol: parts[1], precision };
178
178
  }
179
+ // ── Transfer cap ─────────────────────────────────
180
+ // SECURITY: skills sign their own transactions, bypassing the runner's core
181
+ // maxTransferAmount. Enforce the same XPR ceiling on any XPR the agent SENDS
182
+ // (swaps/deposits/liquidity). MAX_TRANSFER_XPR (default 1000, matching the core
183
+ // default); raise it to allow larger trades.
184
+ function assertXprWithinCap(amount, symbol, label) {
185
+ if ((symbol || '').toUpperCase() !== 'XPR')
186
+ return; // cap is XPR-denominated
187
+ const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
188
+ if (!Number.isFinite(capXpr) || capXpr <= 0)
189
+ return; // disabled/invalid -> no cap
190
+ if (Number.isFinite(amount) && amount > capXpr) {
191
+ throw new Error(`${label}: ${amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
192
+ }
193
+ }
179
194
  // ── Session Factory ──────────────────────────────
180
195
  // Backed by the proton CLI. The agent process never holds a private key —
181
196
  // the CLI signs every transaction internally via its encrypted keychain.
@@ -781,6 +796,7 @@ function defiSkill(api) {
781
796
  depositQuantity = formatAsset(params.amount, bidToken.precision, bidToken.code);
782
797
  depositContract = bidToken.contract;
783
798
  }
799
+ assertXprWithinCap(orderSide === 1 ? params.amount * params.price : params.amount, orderSide === 1 ? askToken.code : bidToken.code, 'defi_place_order');
784
800
  const { api: eosApi, account, permission } = await getSession();
785
801
  const actions = [
786
802
  // 1. Deposit tokens to DEX
@@ -926,6 +942,7 @@ function defiSkill(api) {
926
942
  if (!params.min_output || params.min_output <= 0)
927
943
  return { error: 'min_output must be positive' };
928
944
  try {
945
+ assertXprWithinCap(params.amount, fromSpec.symbol, 'defi_swap');
929
946
  const { api: eosApi, account, permission } = await getSession();
930
947
  const fromQty = formatAsset(params.amount, fromSpec.precision, fromSpec.symbol);
931
948
  const minOutQty = formatAsset(params.min_output, toSpec.precision, toSpec.symbol);
@@ -1010,6 +1027,8 @@ function defiSkill(api) {
1010
1027
  if (!params.token2_contract)
1011
1028
  return { error: 'token2_contract is required' };
1012
1029
  try {
1030
+ assertXprWithinCap(t1.amount, t1.symbol, 'defi_add_liquidity');
1031
+ assertXprWithinCap(t2.amount, t2.symbol, 'defi_add_liquidity');
1013
1032
  const { api: eosApi, account, permission } = await getSession();
1014
1033
  const slip = (params.slippage_pct || 1.0) / 100;
1015
1034
  const min1 = formatAsset(t1.amount * (1 - slip), t1.precision, t1.symbol);
@@ -168,6 +168,20 @@ function parseAssetString(s: string): { amount: number; symbol: string; precisio
168
168
  return { amount, symbol: parts[1], precision };
169
169
  }
170
170
 
171
+ // ── Transfer cap ─────────────────────────────────
172
+ // SECURITY: skills sign their own transactions, bypassing the runner's core
173
+ // maxTransferAmount. Enforce the same XPR ceiling on any XPR the agent SENDS
174
+ // (swaps/deposits/liquidity). MAX_TRANSFER_XPR (default 1000, matching the core
175
+ // default); raise it to allow larger trades.
176
+ function assertXprWithinCap(amount: number, symbol: string, label: string): void {
177
+ if ((symbol || '').toUpperCase() !== 'XPR') return; // cap is XPR-denominated
178
+ const capXpr = Number(process.env.MAX_TRANSFER_XPR || '1000');
179
+ if (!Number.isFinite(capXpr) || capXpr <= 0) return; // disabled/invalid -> no cap
180
+ if (Number.isFinite(amount) && amount > capXpr) {
181
+ throw new Error(`${label}: ${amount} XPR exceeds the transfer cap of ${capXpr} XPR (set MAX_TRANSFER_XPR to raise it).`);
182
+ }
183
+ }
184
+
171
185
  // ── Session Factory ──────────────────────────────
172
186
  // Backed by the proton CLI. The agent process never holds a private key —
173
187
  // the CLI signs every transaction internally via its encrypted keychain.
@@ -790,6 +804,11 @@ export default function defiSkill(api: SkillApi): void {
790
804
  depositContract = bidToken.contract;
791
805
  }
792
806
 
807
+ assertXprWithinCap(
808
+ orderSide === 1 ? params.amount * params.price : params.amount,
809
+ orderSide === 1 ? askToken.code : bidToken.code,
810
+ 'defi_place_order',
811
+ );
793
812
  const { api: eosApi, account, permission } = await getSession();
794
813
 
795
814
  const actions: any[] = [
@@ -933,6 +952,7 @@ export default function defiSkill(api: SkillApi): void {
933
952
  if (!params.min_output || params.min_output <= 0) return { error: 'min_output must be positive' };
934
953
 
935
954
  try {
955
+ assertXprWithinCap(params.amount, fromSpec.symbol, 'defi_swap');
936
956
  const { api: eosApi, account, permission } = await getSession();
937
957
 
938
958
  const fromQty = formatAsset(params.amount, fromSpec.precision, fromSpec.symbol);
@@ -1020,6 +1040,8 @@ export default function defiSkill(api: SkillApi): void {
1020
1040
  if (!params.token2_contract) return { error: 'token2_contract is required' };
1021
1041
 
1022
1042
  try {
1043
+ assertXprWithinCap(t1.amount, t1.symbol, 'defi_add_liquidity');
1044
+ assertXprWithinCap(t2.amount, t2.symbol, 'defi_add_liquidity');
1023
1045
  const { api: eosApi, account, permission } = await getSession();
1024
1046
  const slip = (params.slippage_pct || 1.0) / 100;
1025
1047
  const min1 = formatAsset(t1.amount * (1 - slip), t1.precision, t1.symbol);
@@ -20,7 +20,7 @@ const mockApi = {
20
20
  getConfig() {
21
21
  return {
22
22
  network: 'mainnet',
23
- rpcEndpoint: 'https://proton.eosusa.io',
23
+ rpcEndpoint: 'https://api.protonnz.com',
24
24
  };
25
25
  },
26
26
  };
@@ -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 GOV_API = 'https://gov.api.xprnetwork.org/api/v1/proposals';
10
10
  const GOV = 'gov';
11
11
 
@@ -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
 
10
10
  async function getTableRows(opts) {
11
11
  const resp = await fetch(`${RPC}/v1/chain/get_table_rows`, {