@xpr-agents/openclaw 0.8.0 → 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.
@@ -15,60 +15,75 @@ const DEFAULT_TIMEOUT = 5000;
15
15
  const MAX_TIMEOUT = 30000;
16
16
  const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
17
17
  // ── Sandbox helpers ─────────────────────────────
18
- function createSandboxGlobals(input, logs) {
19
- // Capture console methods
20
- const consoleMock = {
21
- log: (...args) => {
22
- logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
23
- },
24
- warn: (...args) => {
25
- logs.push('[warn] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
26
- },
27
- error: (...args) => {
28
- logs.push('[error] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
29
- },
30
- };
31
- return {
32
- INPUT: input,
33
- console: consoleMock,
34
- JSON,
35
- Math,
36
- Date,
37
- Array,
38
- Object,
39
- String,
40
- Number,
41
- RegExp,
42
- Map,
43
- Set,
44
- parseInt,
45
- parseFloat,
46
- isNaN,
47
- isFinite,
48
- encodeURIComponent,
49
- decodeURIComponent,
50
- atob: (s) => Buffer.from(s, 'base64').toString('binary'),
51
- btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
52
- // Explicitly undefined — blocked
53
- require: undefined,
54
- process: undefined,
55
- globalThis: undefined,
56
- global: undefined,
57
- };
58
- }
59
- function serializeResult(value) {
60
- if (value === undefined)
61
- return 'undefined';
62
- try {
63
- const str = JSON.stringify(value, null, 2);
64
- if (str.length > MAX_OUTPUT_SIZE) {
65
- return str.slice(0, MAX_OUTPUT_SIZE) + '\n... [truncated at 10MB]';
66
- }
67
- return str;
68
- }
69
- catch {
70
- return String(value);
71
- }
18
+ //
19
+ // SECURITY: the context is given ONLY primitive strings — never a host function or
20
+ // object. That is the whole game with node:vm. `codeGeneration.strings:false` only
21
+ // disables eval/Function *for this context's own realm*; a host closure handed in
22
+ // (a console mock, atob/btoa via Buffer, anything) exposes `fn.constructor` — the
23
+ // HOST realm's Function, where code generation is still allowed — so
24
+ // `console.log.constructor("return process.env")()` would read the runner's secrets
25
+ // and `Object.getPrototypeOf(hostFn).constructor.prototype` would pollute the host.
26
+ // So: console, atob/btoa, INPUT parsing and the *result serialization* all run as
27
+ // sandbox-realm code (below). Serializing inside the sandbox also keeps it under the
28
+ // execution timeout — a malicious getter can no longer stall the host via a
29
+ // host-side JSON.stringify. The only value read back out is a JSON string (a
30
+ // primitive), which the host then parses safely.
31
+ /** Sandbox-realm preamble: pure-JS console/atob/btoa/INPUT, no host references. */
32
+ const SANDBOX_PREAMBLE = `
33
+ var __logs = [];
34
+ var console = {
35
+ log: function(){ __logs.push(Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
36
+ warn: function(){ __logs.push('[warn] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); },
37
+ error: function(){ __logs.push('[error] ' + Array.prototype.map.call(arguments, function(a){ return typeof a === 'object' ? JSON.stringify(a) : String(a); }).join(' ')); }
38
+ };
39
+ var __B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
40
+ function btoa(s){ s = String(s); var o = ''; for (var i = 0; i < s.length; ) {
41
+ var c1 = s.charCodeAt(i++), c2 = s.charCodeAt(i++), c3 = s.charCodeAt(i++);
42
+ var e1 = c1 >> 2, e2 = ((c1 & 3) << 4) | (c2 >> 4), e3 = ((c2 & 15) << 2) | (c3 >> 6), e4 = c3 & 63;
43
+ if (isNaN(c2)) { e3 = e4 = 64; } else if (isNaN(c3)) { e4 = 64; }
44
+ o += __B64.charAt(e1) + __B64.charAt(e2) + (e3 === 64 ? '=' : __B64.charAt(e3)) + (e4 === 64 ? '=' : __B64.charAt(e4));
45
+ } return o; }
46
+ function atob(s){ s = String(s).replace(/[^A-Za-z0-9+/=]/g, ''); var o = ''; for (var i = 0; i < s.length; ) {
47
+ 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++));
48
+ var c1 = (d1 << 2) | (d2 >> 4), c2 = ((d2 & 15) << 4) | (d3 >> 2), c3 = ((d3 & 3) << 6) | d4;
49
+ o += String.fromCharCode(c1); if (d3 !== 64 && d3 >= 0) o += String.fromCharCode(c2); if (d4 !== 64 && d4 >= 0) o += String.fromCharCode(c3);
50
+ } return o; }
51
+ `.trim();
52
+ /**
53
+ * Run a self-contained expression/body in a fresh vm realm and return the parsed
54
+ * outcome. `body` must be a statement list whose LAST expression is the user value
55
+ * to capture. Everything is serialized to a JSON string inside the sandbox.
56
+ */
57
+ function runInSandbox(body, timeoutMs, inputJson) {
58
+ // Only a primitive string crosses into the realm — AND the context global is
59
+ // given a NULL prototype. If we hand vm.createContext an ordinary host object,
60
+ // the sandbox global inherits the HOST realm's Object.prototype, so
61
+ // `this.constructor.constructor("return process.env")()` walks to the host
62
+ // Function (where codeGeneration is allowed) and reads the runner's secrets —
63
+ // codeGeneration:false only covers THIS context's realm. A null-proto global
64
+ // makes `this.constructor` resolve to the sandbox realm's own Function, which
65
+ // the flag then blocks. (Confirmed escape via the global-object path, 2026-09-19.)
66
+ const sandboxGlobal = Object.create(null);
67
+ sandboxGlobal.__INPUT_JSON = inputJson;
68
+ const context = vm_1.default.createContext(sandboxGlobal, {
69
+ codeGeneration: { strings: false, wasm: false },
70
+ });
71
+ const wrapped = `${SANDBOX_PREAMBLE}
72
+ var INPUT = (typeof __INPUT_JSON === 'string') ? JSON.parse(__INPUT_JSON) : undefined;
73
+ var __result, __error = null;
74
+ try { __result = (function(){ ${body} \n})(); } catch (e) { __error = (e && e.message) ? String(e.message) : String(e); }
75
+ (function(){
76
+ try { return JSON.stringify({ ok: __error === null, result: __result === undefined ? null : __result, logs: __logs, error: __error }); }
77
+ catch (e) { return JSON.stringify({ ok: __error === null, result: String(__result), logs: __logs, error: __error }); }
78
+ })();`;
79
+ const script = new vm_1.default.Script(wrapped, { filename: 'sandbox.js' });
80
+ const out = script.runInContext(context, { timeout: timeoutMs });
81
+ if (typeof out !== 'string')
82
+ return { ok: false, logs: [], error: 'sandbox produced no serializable output' };
83
+ if (out.length > MAX_OUTPUT_SIZE)
84
+ return { ok: false, logs: [], error: 'Output exceeded 10MB limit', oversized: true };
85
+ const parsed = JSON.parse(out);
86
+ return parsed;
72
87
  }
73
88
  // ── Skill entry point ───────────────────────────
74
89
  function codeSandboxSkill(api) {
@@ -97,48 +112,40 @@ function codeSandboxSkill(api) {
97
112
  return { error: 'code parameter is required and must be a string' };
98
113
  }
99
114
  const timeoutMs = Math.min(Math.max(timeout || DEFAULT_TIMEOUT, 100), MAX_TIMEOUT);
100
- const logs = [];
101
115
  const startTime = Date.now();
116
+ let inputJson;
102
117
  try {
103
- const globals = createSandboxGlobals(input, logs);
104
- const context = vm_1.default.createContext(globals, {
105
- codeGeneration: { strings: false, wasm: false },
106
- });
107
- // Wrap code so the last expression is returned
108
- const wrapped = `(function() {\n${code}\n})()`;
109
- const script = new vm_1.default.Script(wrapped, { filename: 'sandbox.js' });
110
- const result = script.runInContext(context, { timeout: timeoutMs });
118
+ inputJson = input === undefined ? undefined : JSON.stringify(input);
119
+ }
120
+ catch {
121
+ return { error: 'input could not be serialized to JSON' };
122
+ }
123
+ try {
124
+ const out = runInSandbox(code, timeoutMs, inputJson);
111
125
  const durationMs = Date.now() - startTime;
112
- const serialized = serializeResult(result);
113
- if (serialized.length > MAX_OUTPUT_SIZE) {
114
- return {
115
- result: serialized.slice(0, 1000) + '... [truncated]',
116
- logs,
117
- duration_ms: durationMs,
118
- warning: 'Output exceeded 10MB limit and was truncated',
119
- };
120
- }
121
- // Parse back to preserve types (arrays, objects)
122
- let parsed;
123
- try {
124
- parsed = JSON.parse(serialized);
126
+ if (out.oversized) {
127
+ return { error: 'Output exceeded 10MB limit', logs: out.logs, duration_ms: durationMs, warning: 'Output exceeded 10MB limit and was truncated' };
125
128
  }
126
- catch {
127
- parsed = serialized === 'undefined' ? undefined : serialized;
129
+ if (!out.ok) {
130
+ const message = out.error || 'unknown error';
131
+ if (message.includes('Code generation from strings disallowed')) {
132
+ return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: out.logs, duration_ms: durationMs };
133
+ }
134
+ return { error: message, logs: out.logs, duration_ms: durationMs };
128
135
  }
129
- return { result: parsed, logs, duration_ms: durationMs };
136
+ return { result: out.result, logs: out.logs, duration_ms: durationMs };
130
137
  }
131
138
  catch (err) {
132
139
  const durationMs = Date.now() - startTime;
133
140
  const message = err.message || String(err);
134
- // Provide helpful error context
135
- if (message.includes('Script execution timed out')) {
136
- return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs, duration_ms: durationMs };
141
+ // Timeout is a hard interrupt thrown to the host, so it lands here.
142
+ if (message.includes('Script execution timed out') || message.includes('timed out')) {
143
+ return { error: `Execution timed out after ${timeoutMs}ms. Keep code efficient or increase timeout (max ${MAX_TIMEOUT}ms).`, logs: [], duration_ms: durationMs };
137
144
  }
138
145
  if (message.includes('Code generation from strings disallowed')) {
139
- return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs, duration_ms: durationMs };
146
+ return { error: 'eval() and Function() constructor are blocked in the sandbox. Use direct code instead.', logs: [], duration_ms: durationMs };
140
147
  }
141
- return { error: message, logs, duration_ms: durationMs };
148
+ return { error: message, logs: [], duration_ms: durationMs };
142
149
  }
143
150
  },
144
151
  });
@@ -162,26 +169,20 @@ function codeSandboxSkill(api) {
162
169
  return { error: 'expression parameter is required and must be a string' };
163
170
  }
164
171
  try {
165
- const globals = createSandboxGlobals(undefined, []);
166
- const context = vm_1.default.createContext(globals, {
167
- codeGeneration: { strings: false, wasm: false },
168
- });
169
- const script = new vm_1.default.Script(`(${expression})`, { filename: 'expr.js' });
170
- const result = script.runInContext(context, { timeout: DEFAULT_TIMEOUT });
171
- let serialized;
172
- try {
173
- serialized = JSON.parse(JSON.stringify(result));
174
- }
175
- catch {
176
- serialized = String(result);
177
- }
178
- return {
179
- result: serialized,
180
- type: result === null ? 'null' : Array.isArray(result) ? 'array' : typeof result,
181
- };
172
+ // Evaluate as the returned value of the sandbox body (same isolated realm,
173
+ // in-sandbox serialization). The value comes back as parsed JSON.
174
+ const out = runInSandbox(`return (${expression});`, DEFAULT_TIMEOUT, undefined);
175
+ if (!out.ok)
176
+ return { error: out.error || 'evaluation failed' };
177
+ const r = out.result;
178
+ const type = r === null ? 'null' : Array.isArray(r) ? 'array' : typeof r;
179
+ return { result: r, type };
182
180
  }
183
181
  catch (err) {
184
- return { error: err.message || String(err) };
182
+ const message = err.message || String(err);
183
+ if (message.includes('timed out'))
184
+ return { error: `Execution timed out after ${DEFAULT_TIMEOUT}ms.` };
185
+ return { error: message };
185
186
  }
186
187
  },
187
188
  });
@@ -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
+ }