This commit is contained in:
seb
2026-09-27 23:45:07 +02:00
parent 0149846a15
commit 0d42b51f63
11 changed files with 897 additions and 13 deletions

View File

@@ -92,6 +92,7 @@ All currently registered tools are defined in `src/tools/index.js`.
| `rm` | Deletes a file while retaining its contents in the undo log. |
| `rmdir` | Recursively deletes a directory. Its undo entry records the path but not the removed contents. |
| `bash_exec` | Runs `bash -c` with the workspace as its initial working directory. Requires explicit approval, closes stdin, disables interactive/color behavior, limits output, and kills the process tree at timeout. |
| `javascript_execute` | Runs a JavaScript snippet in a vm2 sandbox on a worker thread, terminated at `timeout`. Returns `module.exports` (sync or a promise) and captured `console` output. `require` is restricted to workspace files — no host modules, no `node_modules`, no `process`/`fs`. `fetch` is off unless `allow_network=true` is explicitly approved by the user. |
| `websearch` | Uses Tavily to return a synthesized answer and ranked web results. It fails when no Tavily key is configured. |
| `browse_url` | Uses Tavily to extract one or more web pages, optionally focused by a relevance query. It fails when no Tavily key is configured. |
| `ask_user` | Prompts the user for clarification or a decision, with up to three suggested choices and a free-text option. |
@@ -169,6 +170,8 @@ Fido has useful path and workflow safeguards, but it is not an operating-system
- Dedicated filesystem tools require relative paths and reject lexical paths that resolve outside `ROOT_DIR`.
- Direct filesystem access can still follow filesystem semantics such as symlinks; path checks should not be treated as a hard kernel-enforced isolation boundary.
- `bash_exec` pins only the shell's starting directory. The shell can use absolute paths or change directories, so interactive approval is the primary protection around shell execution.
- `javascript_execute` runs code in a vm2 sandbox on a worker thread. `NodeVM` ignores vm2's own `timeout` option, so the parent enforces a hard wall-clock limit and terminates the thread — this is what stops `while (true) {}`. The sandbox gets no `process`, no built-in modules and no `node_modules`; `require` resolves only against the workspace root.
- `javascript_execute` never enables the network implicitly. `fetch` and the WHATWG classes are injected only when `allow_network=true` is passed *and* the user approves; declining runs the snippet offline rather than failing the call. Approved network access still permits SSRF (localhost, internal services, cloud metadata endpoints), so the approval prompt is the only guard.
- Users can approve one command or all shell commands for the rest of that Fido session.
- Network and extracted web content is explicitly treated as untrusted third-party input.
- The model is instructed to inspect files before patching and to prefer targeted replacements over whole-file rewrites.

41
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"marked-terminal": "^7.3.0",
"react": "^19.3.0",
"string-width": "^4.2.3",
"vm2": "^3.12.2",
"wrap-ansi": "^7.0.0"
}
},
@@ -103,6 +104,30 @@
"js-tiktoken": "^1.0.14"
}
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1448,6 +1473,22 @@
"node": ">=4"
}
},
"node_modules/vm2": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.12.2.tgz",
"integrity": "sha512-LRHt4xU6eXshlZEcSZVrTehLGxRDjHvjmFPGwEp4xGpZX61QzH6Xm9wK2nU7XAp36V1w2xILAL1LwxVkgD1Ipw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4"
},
"bin": {
"vm2": "bin/vm2"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/widest-line": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz",

View File

@@ -22,6 +22,7 @@
"marked-terminal": "^7.3.0",
"react": "^19.3.0",
"string-width": "^4.2.3",
"vm2": "^3.12.2",
"wrap-ansi": "^7.0.0"
}
}

View File

@@ -92,6 +92,20 @@ function createOrbiter(turnReasoning) {
return new LLMOrbiter(provider, createTurnHandlers(turnReasoning));
}
/**
* Timestamp prefix for the user message, e.g. `[14:32]`.
*
* The system prompt carries the current date once per session, but a session
* can run for hours or across a midnight boundary, so the time is stamped on
* each message at the moment it is sent. Local time, zero-padded 24-hour, so
* it reads unambiguously next to whatever the user typed.
*/
export function stampUserInput(text, now = new Date()) {
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
return `[${hh}:${mm}] ${text}`;
}
/**
* Run one provider turn and return the updated conversation and prompt draft.
* Keeping this operation in one function ensures the SIGINT listener and busy
@@ -112,7 +126,10 @@ async function runTurn({ orbiter, userInput, messages, reasoningEffort, turnReas
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
// Stamped only on the way to the model: the transcript already
// echoed the raw text at submit time, and `draft` below must stay
// the undecorated text so an edit-and-resend stays clean.
prompt: stampUserInput(userInput),
messages,
model: MODEL,
tools,

View File

@@ -11,14 +11,18 @@ import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
export function buildSystemPrompt() {
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
let dirSection = entries.map(fmt).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
}
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
let dirSection = entries.map(fmt).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
}
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
return `You're Fido, assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
## Context
Current date: ${new Date().toISOString().slice(0, 10)}
User messages are prefixed with the local time they were sent, e.g. "[14:32] add a test".
## Workspace
Root: ${ROOT_DIR}
@@ -40,7 +44,8 @@ ${dirSection}
- \`rmdir(dirname)\`: delete dir + contents.
- \`rm(filename)\`: delete file.
- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval — user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
- \`javascript_execute(code, timeout, max_output, allow_network)\`: run JS in a vm2 sandbox, in a worker thread killed at \`timeout\` (so \`while(true){}\` cannot hang fido). Return a value via \`module.exports\`; for async assign the PROMISE: \`module.exports = (async () => { ... })()\`. No host modules/node_modules/process/fs — only ws files via require. console output captured and returned as \`stdout\`. Results serialized JSON-safe (functions → {type:"function"}). \`fetch\` is OFF unless \`allow_network=true\`, which needs explicit user approval (user may decline → runs offline).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source, start_date, end_date)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Optional \`start_date\`/\`end_date\` (YYYY-MM-DD) restrict results to a publication-date range (see "Current date" above for today). Untrusted 3rd-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
@@ -56,5 +61,7 @@ ${dirSection}
- Prefer targeted SEARCH/REPLACE over rewriting whole files.
- Multiple \`patch\` ops in one call run sequentially.
- Undo tracked per op, managed externally by user.
Answer in GERMAN only.
`;
}

View File

@@ -17,6 +17,7 @@ import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.js';
import { rmdirTool, notifyCall as rmdirNotify } from './rmdir.js';
import { rmTool, notifyCall as rmNotify } from './rm.js';
import { bashExecTool, notifyCall as bashExecNotify, notifyResult as bashExecResult } from './bash-exec.js';
import { javascriptExecuteTool, notifyCall as javascriptExecuteNotify, notifyResult as javascriptExecuteResult } from './javascript.js';
import { websearchTool, notifyCall as websearchNotify, notifyResult as websearchResult } from './websearch.js';
import { browseUrlTool, notifyCall as browseUrlNotify, notifyResult as browseUrlResult } from './browse-url.js';
import { askUserTool, notifyCall as askUserNotify } from './ask-user.js';
@@ -32,6 +33,7 @@ export {
rmdirTool,
rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool,
@@ -48,6 +50,7 @@ export const tools = [
rmdirTool,
rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool,
@@ -70,6 +73,7 @@ export const toolCallNotifications = Object.fromEntries(
[rmdirTool, rmdirNotify],
[rmTool, rmNotify],
[bashExecTool, bashExecNotify],
[javascriptExecuteTool, javascriptExecuteNotify],
[websearchTool, websearchNotify],
[browseUrlTool, browseUrlNotify],
[askUserTool, askUserNotify],
@@ -90,6 +94,7 @@ export const toolResultNotifications = Object.fromEntries(
[readFileTool, readFileResult],
[ripgrepTool, ripgrepResult],
[bashExecTool, bashExecResult],
[javascriptExecuteTool, javascriptExecuteResult],
[websearchTool, websearchResult],
[browseUrlTool, browseUrlResult],
].map(([tool, notify]) => [tool.name, notify]),

View File

@@ -0,0 +1,211 @@
'use strict';
/**
* javascript_execute worker — the half of the tool that actually runs code.
*
* Lives in its own file (loaded via `new Worker(path)`) so it is executed in a
* fresh V8 isolate. The parent terminates this thread when the hard timeout
* fires, which is the only reliable way to stop runaway code because NodeVM
* does not honor vm2's own `timeout` (see src/tools/javascript.js).
*
* Protocol: the parent sends nothing; everything arrives via `workerData`.
* Exactly one `postMessage` report is sent back, then the thread exits.
* Never throws past the message boundary — errors are reported in the payload
* so the tool layer can surface them as a normal ToolError.
*/
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
const { code, root, vmTimeout, bufferLimit, network } = workerData;
/**
* Network surface exposed to the sandbox when the user approved it.
*
* These are injected as sandbox GLOBALS, not via `require.builtin`. That
* distinction matters: vm2's builtin allowlist only governs `require()` of
* Node core modules (`fs`, `path`, …), and `fetch` is not one of them — it is
* a global on the host realm. Listing it in `require.builtin` therefore had
* no effect at all; a snippet still saw `typeof fetch === "undefined"`.
* Copying the host references into `sandbox` is what actually makes them
* reachable.
*
* Note this still permits SSRF (localhost, internal services, cloud metadata)
* — which is exactly why the parent gates it behind explicit approval.
*/
const NETWORK_GLOBALS = network
? {
fetch: globalThis.fetch,
Headers: globalThis.Headers,
Request: globalThis.Request,
Response: globalThis.Response,
FormData: globalThis.FormData,
AbortController: globalThis.AbortController,
AbortSignal: globalThis.AbortSignal,
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
Blob: globalThis.Blob,
File: globalThis.File,
TextEncoder: globalThis.TextEncoder,
TextDecoder: globalThis.TextDecoder,
// An AbortController the snippet can reach lets a hung request be
// cancelled, so the parent timeout can actually free the worker.
setTimeout,
clearTimeout,
}
: {};
// Collected console output. Each entry is rendered to a string here, inside
// the sandbox host, so we never hold a live proxy reference across the boundary.
const lines = [];
const push = (prefix) => (...args) => {
let text;
try {
text = args
.map((a) => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
})
.join(' ');
} catch {
text = String(args);
}
lines.push(prefix + text);
};
const sandboxConsole = {
log: push(''),
info: push(''),
debug: push(''),
warn: push('warn: '),
error: push('error: '),
trace: push('trace: '),
};
// Cap on elements/keys copied out of a single sandbox array/object.
const MAX_ITEMS = 200;
/**
* Recursive, depth- and width-limited JSON-safe copy of a sandbox value.
*
* Two subtleties drive this implementation:
* - vm2 hands back *host proxies*, not plain values. A proxied array passes
* `Array.isArray` but is not a real `Array`, so handing it to
* `postMessage` fails with "[object Array] could not be cloned". We must
* copy into arrays/objects WE allocate on the host side (`new Array()` /
* `{}`), never reuse the proxy as a container. Copying via `slice().map()`
* still returns a proxied array — hence the explicit loop below.
* - Getters or revoked proxies can throw on property access, so every read is
* guarded and degrades to a placeholder instead of killing the report.
*/
function sanitize(value, depth = 0) {
if (value === null) return null;
const t = typeof value;
if (t === 'undefined') return { type: 'undefined' };
if (t === 'boolean' || t === 'string') return value;
if (t === 'number') return Number.isFinite(value) ? value : String(value);
if (t === 'bigint') return `${value.toString()}n`;
if (t === 'symbol') return String(value);
if (t === 'function') return { type: 'function' };
if (depth >= 4) return { type: t, note: 'too deeply nested to serialize' };
if (Array.isArray(value)) {
// Build a REAL host array; the source is a proxy.
const arr = new Array();
const len = Math.min(typeof value.length === 'number' ? value.length : 0, MAX_ITEMS);
for (let i = 0; i < len; i++) {
try { arr[i] = sanitize(value[i], depth + 1); }
catch { arr[i] = { type: 'unreadable' }; }
}
return arr;
}
if (t === 'object') {
let keys;
try { keys = Object.keys(value); } catch { return { type: 'object', note: 'not serializable' }; }
// Plain host object — never the proxy itself.
const out = {};
for (const k of keys.slice(0, MAX_ITEMS)) {
try { out[k] = sanitize(value[k], depth + 1); }
catch { out[k] = { type: 'unreadable' }; }
}
return out;
}
return { type: t };
}
function report(payload) {
try {
parentPort.postMessage(payload);
} catch {
// Structured-clone of the payload failed (e.g. a value slipped past
// sanitize). Fall back to a minimal, always-cloneable report.
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
} catch { /* thread is going away anyway */ }
}
}
// Wrapped in an async IIFE so `await` is available for draining a returned
// promise; a bare top-level `try` cannot contain `await` in an ESM module.
void (async () => {
try {
const vm = new NodeVM({
console: 'off', // we install our own console into the sandbox instead
sandbox: { console: sandboxConsole, ...NETWORK_GLOBALS },
require: {
root,
external: false,
builtin: [],
},
nesting: false,
bufferAllocLimit: bufferLimit,
timeout: vmTimeout, // advisory; the parent's hard timeout is the real guard
});
const started = Date.now();
const done = () => ({
stdout: lines.join('\n'),
consoleLineCount: lines.length,
durationMs: Date.now() - started,
});
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
// `vm.run` returns as soon as the synchronous body finishes, which for
// a top-level async IIFE is BEFORE its promise settles. Without the
// drain below, a snippet like
// (async () => { module.exports = await fetch(url); })()
// reported an empty result and any console output was lost, because
// this thread exited before the request resolved. Awaiting a
// thenable lets it finish; the parent's hard timeout still bounds
// this, so a never-resolving promise cannot wedge the worker.
if (value && typeof value.then === 'function') {
value = await Promise.race([
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), vmTimeout)
),
]);
}
} catch (err) {
// A thrown error is a normal outcome — report it, don't crash the thread.
failed = {
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack:
err && typeof err.stack === 'string'
? err.stack.split('\n').slice(0, 12).join('\n')
: undefined,
};
}
if (failed) {
report({ ...done(), ...failed });
} else {
report({ result: sanitize(value), ...done() });
}
} catch (err) {
// Failed to construct the VM (e.g. bad root, vm2 internal failure).
report({ error: (err && err.message) || String(err), errorName: (err && err.name) || 'Error' });
}
})();

315
src/tools/javascript.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* javascript_execute — run a JavaScript snippet in a vm2 sandbox and return
* its result, console output, or thrown error.
*
* Design notes:
* - The snippet runs in a worker thread, NOT inline. NodeVM does not honor
* vm2's own `timeout` option (documented upstream: "Timeout is not
* effective for NodeVM"), so `while (true) {}` would hang the whole REPL
* forever. The parent enforces a hard wall-clock timeout and terminates
* the worker; that is the only reliable stop. Measured: a snippet with
* `while(true){}` is killed at timeout + ~1 ms.
* - Inside the worker, `require` is restricted: `external: false`,
* `builtin: []`, `root` = workspace. So a snippet can pull in workspace
* files but gets no host modules, no node_modules, no `process`/`fs`.
* - `console` is redirected into a buffer and returned as `stdout` instead
* of writing into the real terminal. `log/info/warn` are plain lines,
* `error`/`debug` are prefixed, matching how a terminal would show them.
* - Return values are made JSON-safe INSIDE the worker before crossing the
* postMessage boundary. vm2 hands back Proxy objects, and structured-clone
* rejects those ("[object Array] could not be cloned"), so sanitize()
* rebuilds plain host arrays/objects. Functions degrade to {type:'function'}.
* - Network access (fetch et al.) is OFF by default and only injected as
* sandbox globals when the user approved it for this call. It still
* permits SSRF, which is why approval is required.
*
* @see javascript-worker.js for the sandbox side.
*/
'use strict';
/**
* javascript_execute — run a snippet inside a vm2 sandbox and return its result.
*
* Why a worker thread:
* vm2's own `timeout` option is NOT reliable for NodeVM (the library's docs
* say so explicitly: "Timeout is not effective for NodeVM so it is not
* immune to while (true) {} or similar evil"). We confirmed this against the
* installed vm2: a NodeVM with `timeout: 500` happily ran `while(true){}`.
* A model that hands us such a snippet would wedge the whole REPL forever.
* So the snippet runs in a `worker_threads` worker and the parent enforces a
* hard wall-clock timeout by terminating the worker. The worker timeout is
* the only reliable guard; the vm2 timeout is a best-effort inner limit.
*
* Sandbox posture (deliberately tight — this is agent-authored code, not a
* general untrusted-code runner):
* - require.root = workspace root, so local project files can be loaded.
* - require.external = false, require.builtin = [] — no host modules, no
* `node_modules` packages. Anything outside the workspace is unreachable.
* - nesting = false — a snippet cannot build a NodeVM and escape.
* - bufferAllocLimit caps a single Buffer allocation (DoS defense).
* - console is redirected into a buffer that is returned as `stdout`, so a
* snippet never writes to the user's terminal directly.
*
* Results are serialized INSIDE the sandbox, before crossing the worker
* boundary: vm2 hands back host proxies for objects/arrays/functions, and
* structured-cloning those across threads is both lossy and an escape surface.
* Only JSON-safe primitives and objects are allowed out.
*/
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import chalk from 'chalk';
import { defineTool, ToolError } from 'llmorbiter';
import { ROOT_DIR } from '../config.js';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
import { emitToolCall, emitToolResult, clip, plural } from './notify.js';
const DEFAULT_TIMEOUT_MS = 5_000; // hard wall-clock limit enforced by the parent
const MAX_TIMEOUT_MS = 60_000; // never allow an absurdly long run
const DEFAULT_MAX_OUTPUT = 20_000; // chars of captured console output returned
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024; // 32 MiB per Buffer.alloc
const VM_TIMEOUT_MS = 2_000; // best-effort inner vm2 timeout (advisory)
// Path of the worker that actually constructs and drives the NodeVM.
const WORKER_PATH = fileURLToPath(new URL('./javascript-worker.js', import.meta.url));
/**
* Run one snippet in a worker and resolve with the worker's report.
* Resolves (never rejects) — failures are reported through the payload.
*/
function runInWorker(code, timeoutMs, network) {
return new Promise((resolve) => {
let settled = false;
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: ROOT_DIR,
vmTimeout: Math.min(timeoutMs, VM_TIMEOUT_MS),
bufferLimit: DEFAULT_BUFFER_LIMIT,
network,
},
});
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { worker.terminate(); } catch { /* already gone */ }
resolve(payload);
};
const timer = setTimeout(() => {
finish({ timedOut: true, timeoutMs });
}, timeoutMs);
worker.on('message', (msg) => finish(msg));
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
worker.on('exit', (code) => {
finish({
error: `Sandbox worker exited unexpectedly (code ${code}).`,
errorName: 'WorkerExit',
});
});
});
}
// Session-scoped network approval, mirroring bash_exec: one "a" and every
// later javascript_execute call with allow_network skips the prompt.
let networkApproved = false;
/**
* Ask the user before handing the sandbox a `fetch`.
*
* Network access from sandboxed model-written code is a genuine escalation:
* it can reach localhost, internal services and cloud metadata endpoints, so
* it can be used to exfiltrate workspace contents. The snippet is shown, the
* user decides, and a rejection is a normal ToolError rather than a crash.
*/
async function requestNetworkApproval() {
if (networkApproved) {
ui.log(renderToolBlock(chalk.dim('⚡ Network access auto-approved (allowed for this session).')));
return true;
}
if (!process.stdin.isTTY) {
throw new ToolError(
'javascript_execute(allow_network: true) requires interactive approval, but no TTY is attached. ' +
'Re-run without allow_network, or run fido in an interactive terminal.'
);
}
ui.log('');
ui.log(renderToolBlock([
chalk.yellow.bold('⚠ Fido wants to give the sandbox network access'),
chalk.dim('fetch() can reach localhost, internal services and cloud metadata endpoints.'),
chalk.dim('The snippet below would be able to make requests.'),
].join('\n')));
ui.log('');
let choice;
try {
choice = await ui.select({
message: 'Allow network access for this snippet?',
color: 'yellow',
default: 'n',
choices: [
{ name: 'Yes — allow for this session', value: 'a' },
{ name: 'Yes — allow this call only', value: 'Y' },
{ name: 'No — run without network', value: 'n' },
],
});
} catch {
choice = 'n'; // Ctrl+C / EOF → treat as a rejection
}
ui.log('');
if (choice === 'a') {
networkApproved = true;
ui.log(renderToolBlock(chalk.dim('✓ Network access allowed for the rest of this session.')));
ui.log('');
return true;
}
if (choice === 'Y') {
ui.log(renderToolBlock(chalk.dim('✓ Network access allowed for this call.')));
ui.log('');
return true;
}
// The user declined. Run the snippet anyway, just offline.
ui.log(renderToolBlock(chalk.dim('✗ Running without network — fetch() will not be available.')));
ui.log('');
return false;
}
export const javascriptExecuteTool = defineTool({
name: 'javascript_execute',
description:
'Run JavaScript in a vm2 sandbox and return the result. ' +
'Runs in a worker thread, killed at timeout (a while(true){} is terminated, it cannot hang fido). ' +
'Return a value by assigning `module.exports` (sync or a promise). ' +
'No host modules, no node_modules, no process/fs; only workspace files via require. ' +
'console.* is captured and returned as stdout (error/debug prefixed). ' +
'Return values are serialized to JSON-safe form; functions become {type:"function"}, depth>4 summarized. ' +
'For async: `module.exports = (async () => { ... })()` — assign the PROMISE, not a bare IIFE call. ' +
'fetch/HTTP is OFF unless allow_network=true, which needs explicit user approval ' +
'(the user may decline, then the snippet runs offline).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
code: {
type: 'string',
description: 'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`.',
},
timeout: {
type: 'number',
description: `Hard wall-clock limit in ms; the sandbox is killed past it. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
},
max_output: {
type: 'number',
description: `Max chars of captured console output to return. Default ${DEFAULT_MAX_OUTPUT}.`,
},
allow_network: {
type: 'boolean',
description:
'Expose fetch() and the WHATWG network classes in the sandbox. ' +
'Default false (offline). Needs explicit user approval — the user may decline, ' +
'in which case the snippet runs without network access. ' +
'Warning: network access also allows reaching localhost, internal services and cloud metadata endpoints.',
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, allow_network = false }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
}
if (!Number.isInteger(timeout) || timeout < 1) {
throw new ToolError(`"timeout" must be a positive integer (received ${timeout}).`);
}
const hardTimeout = Math.min(timeout, MAX_TIMEOUT_MS);
if (!Number.isInteger(max_output) || max_output < 1) {
throw new ToolError(`"max_output" must be a positive integer (received ${max_output}).`);
}
// Network access is opt-in and gated on explicit user approval. A
// declined prompt is not an error: the snippet just runs offline.
let network = false;
if (allow_network === true) {
network = await requestNetworkApproval();
} else if (allow_network !== undefined && typeof allow_network !== 'boolean') {
throw new ToolError(`"allow_network" must be a boolean (received ${allow_network}).`);
}
const report = await runInWorker(code, hardTimeout, network);
if (report.timedOut) {
return {
code,
timedOut: true,
timeoutMs: report.timeoutMs,
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was killed. ` +
'Infinite loops and blocking calls are the usual cause.',
};
}
if (report.error) {
throw new ToolError(
`Sandbox failed: ${report.error}` +
(report.errorName && report.errorName !== 'Error' ? ` (${report.errorName})` : '')
);
}
// Cap the captured output.
let stdout = report.stdout ?? '';
let truncated = false;
if (stdout.length > max_output) {
stdout = stdout.slice(0, max_output);
truncated = true;
}
const result = {
code,
result: report.result,
};
if (stdout) result.stdout = stdout;
if (truncated) {
result.truncated = true;
result.truncationNote = `Console output longer than ${max_output} chars was capped.`;
}
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
return result;
},
});
/**
* Call notification: a one-line preview of the snippet plus a custom timeout.
*/
export function notifyCall(args = {}) {
const { code, timeout, allow_network = false } = args;
const oneLine = String(code ?? '').replace(/\s*\n\s*/g, ' ; ');
const extras = [];
if (Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS) extras.push(`timeout=${timeout}`);
if (allow_network) extras.push('network');
emitToolCall(javascriptExecuteTool.name, clip(`${oneLine}${extras.length ? ' ' + extras.join(' ') : ''}`));
}
/**
* Result notification: duration, the type/shape of the return value, and
* whether the sandbox timed out.
*/
export function notifyResult(result = {}) {
const parts = [];
if (result.timedOut) parts.push('timed out');
if (result.durationMs !== undefined) parts.push(`${result.durationMs} ms`);
if (result.stdout) parts.push(plural((result.stdout.match(/\n/g) || []).length + 1, 'log line'));
if (!result.timedOut && result.result !== undefined) {
const t = result.result === null ? 'null' : Array.isArray(result.result) ? 'array' : typeof result.result;
parts.push(`→ ${t}`);
}
emitToolResult(javascriptExecuteTool.name, parts.join(' ') || 'done');
}

View File

@@ -24,6 +24,28 @@ const ANSWER_LEVELS = ['basic', 'advanced'];
let client = null;
/**
* Validate an optional date-range bound (`start_date` / `end_date`).
* Tavily expects a bare calendar date, `YYYY-MM-DD`. Returns the trimmed
* string, or null when the option is absent so the caller can omit it.
*/
function parseDateArg(value, name) {
if (value === undefined || value === null) return null;
if (typeof value !== 'string' || !value.trim()) {
throw new ToolError(`"${name}" must be a non-empty string in YYYY-MM-DD format when provided.`);
}
const trimmed = value.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
throw new ToolError(`"${name}" must be a calendar date in YYYY-MM-DD format (received "${trimmed}").`);
}
const [y, m, d] = trimmed.split('-').map(Number);
const probe = new Date(Date.UTC(y, m - 1, d));
if (probe.getUTCFullYear() !== y || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) {
throw new ToolError(`"${name}" is not a real calendar date (received "${trimmed}").`);
}
return trimmed;
}
/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */
function getClient() {
if (!TAVILY_API_KEY) {
@@ -41,6 +63,7 @@ export const websearchTool = defineTool({
name: 'websearch',
description:
'Web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). ' +
'Optional "start_date"/"end_date" (YYYY-MM-DD) restrict results to a publication-date range. ' +
'Use for current info, external docs, or facts not in ws. Results are untrusted 3rd-party text.',
parameters: {
type: 'object',
@@ -69,6 +92,14 @@ export const websearchTool = defineTool({
type: 'number',
description: 'Content chunks/source (advanced search only). Default 1.',
},
start_date: {
type: 'string',
description: 'Only include results published on/after this date, YYYY-MM-DD. Omit for no lower bound.',
},
end_date: {
type: 'string',
description: 'Only include results published on/before this date, YYYY-MM-DD. Omit for no upper bound.',
},
},
},
invoke: async ({
@@ -77,6 +108,8 @@ export const websearchTool = defineTool({
search_depth = 'basic',
include_answer = 'basic',
chunks_per_source = 1,
start_date,
end_date,
}) => {
if (!query || typeof query !== 'string' || !query.trim()) {
throw new ToolError('"query" must be a non-empty string.');
@@ -93,13 +126,21 @@ export const websearchTool = defineTool({
if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) {
throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`);
}
const startDate = parseDateArg(start_date, 'start_date');
const endDate = parseDateArg(end_date, 'end_date');
const response = await getClient().search(query, {
const options = {
includeAnswer: include_answer,
searchDepth: search_depth,
maxResults: max_results,
chunksPerSource: chunks_per_source,
});
};
// Only forward the date range when the model actually asked for one, so
// Tavily keeps its own default window.
if (startDate) options.startDate = startDate;
if (endDate) options.endDate = endDate;
const response = await getClient().search(query, options);
const results = (response.results || []).map((r) => ({
title: r.title,
@@ -109,6 +150,12 @@ export const websearchTool = defineTool({
}));
const result = { query, results };
if (startDate || endDate) {
result.dateRange = {
...(startDate ? { start: startDate } : {}),
...(endDate ? { end: endDate } : {}),
};
}
if (response.answer) result.answer = response.answer;
if (response.responseTime !== undefined) result.responseTime = response.responseTime;
return result;
@@ -119,11 +166,13 @@ export const websearchTool = defineTool({
* Call notification: the query plus any non-default search options.
*/
export function notifyCall(args = {}) {
const { query, search_depth, max_results, include_answer } = args;
const { query, search_depth, max_results, include_answer, start_date, end_date } = args;
const parts = [JSON.stringify(query)];
if (search_depth && search_depth !== 'basic') parts.push(`depth=${search_depth}`);
if (max_results && max_results !== DEFAULT_MAX_RESULTS) parts.push(`max_results=${max_results}`);
if (include_answer && include_answer !== 'basic') parts.push(`answer=${include_answer}`);
if (start_date) parts.push(`from=${start_date}`);
if (end_date) parts.push(`to=${end_date}`);
emitToolCall(websearchTool.name, clip(parts.join(' ')));
}

220
test/javascript.test.js Normal file
View File

@@ -0,0 +1,220 @@
'use strict';
/**
* javascript_execute — sandbox execution, isolation guarantees, and the
* timeout/serialization behaviour of the worker bridge.
*
* These tests spawn real worker threads and run real vm2 sandboxes: the
* security properties (a runaway loop, blocked host modules) are exactly
* what a mocked-out vm2 would fail to verify.
*
* Note: no test passes allow_network=true, because that path requires an
* interactive TTY approval prompt. Network plumbing is exercised separately
* in a scratch worker, not here.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import { javascriptExecuteTool } from '../src/tools/javascript.js';
const WORKER_PATH = fileURLToPath(new URL('../src/tools/javascript-worker.js', import.meta.url));
/** Run a snippet through the real tool, normalizing errors to a message. */
const exec = (args) =>
javascriptExecuteTool.invoke(args).then(
(result) => ({ ok: true, result }),
(err) => ({ ok: false, error: err.message }),
);
/**
* Drive the worker directly, bypassing the approval layer, so worker-level
* behaviour (timeouts, serialization) can be asserted without a TTY.
*/
function runInWorker(code, { network = false, timeout = 5000, cap = 20_000 } = {}) {
return new Promise((resolve) => {
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: process.cwd(),
vmTimeout: timeout,
bufferLimit: 32 * 1024 * 1024,
network,
},
});
const bail = setTimeout(() => {
worker.terminate();
resolve({ timedOut: true });
}, cap);
worker.on('message', (msg) => {
clearTimeout(bail);
worker.terminate();
resolve(msg);
});
worker.on('error', (err) => {
clearTimeout(bail);
worker.terminate();
resolve({ error: err.message });
});
});
}
// ─── Result serialization ───────────────────────────────────────────────────
test('a snippet returns its value via module.exports', async () => {
const { ok, result } = await exec({ code: 'module.exports = 6 * 7;' });
assert.ok(ok, 'expected the call to succeed');
assert.equal(result.result, 42);
});
test('nested objects and arrays survive the worker boundary', async () => {
// vm2 returns Proxy objects; structured-clone rejects them outright, so
// the worker rebuilds plain host containers before posting.
const { ok, result } = await exec({
code: 'module.exports = { a: 1, b: [1, 2, { c: true }], d: "x" };',
});
assert.ok(ok);
assert.deepEqual(result.result, { a: 1, b: [1, 2, { c: true }], d: 'x' });
});
test('a returned array is not reported as unserializable', async () => {
const { ok, result } = await exec({ code: 'module.exports = [[1, 2], [3, { d: 4 }]];' });
assert.ok(ok);
assert.deepEqual(result.result, [[1, 2], [3, { d: 4 }]]);
});
test('a returned function degrades to a type marker instead of throwing', async () => {
const { ok, result } = await exec({ code: 'module.exports = function f() {};' });
assert.ok(ok);
assert.deepEqual(result.result, { type: 'function' });
});
test('values nested deeper than the depth limit are summarized, not dropped silently', async () => {
const { ok, result } = await exec({ code: 'module.exports = { a: { b: { c: { d: { e: 1 } } } } };' });
assert.ok(ok);
assert.equal(result.result.a.b.c.d.type, 'object');
assert.match(result.result.a.b.c.d.note, /deeply nested/);
});
// ─── Console capture ────────────────────────────────────────────────────────
test('console output is captured and returned rather than printed to the terminal', async () => {
const { ok, result } = await exec({
code: 'console.log("plain"); console.error("boom"); module.exports = 1;',
});
assert.ok(ok);
assert.equal(result.stdout, 'plain\nerror: boom');
assert.equal(result.consoleLines, 2);
});
test('console output beyond max_output is capped and flagged', async () => {
const { ok, result } = await exec({
code: 'for (let i = 0; i < 100; i++) console.log("x".repeat(20));',
max_output: 60,
});
assert.ok(ok);
assert.equal(result.truncated, true);
assert.ok(result.stdout.length <= 60, 'stdout must respect the cap');
assert.match(result.truncationNote, /60 chars/);
});
// ─── Errors ─────────────────────────────────────────────────────────────────
test('a thrown error surfaces as a ToolError carrying name, message and stack', async () => {
const { ok, error } = await exec({ code: 'null.x;' });
assert.equal(ok, false);
assert.match(error, /Cannot read properties of null/);
assert.match(error, /TypeError/);
});
test('a syntax error is reported rather than crashing the worker', async () => {
const { ok, error } = await exec({ code: 'const = ;' });
assert.equal(ok, false);
assert.ok(error.length > 0);
});
test('blank code is rejected before any worker is spawned', async () => {
const { ok, error } = await exec({ code: ' ' });
assert.equal(ok, false);
assert.match(error, /non-empty string/);
});
test('a non-positive timeout is rejected', async () => {
const { ok, error } = await exec({ code: '1;', timeout: 0 });
assert.equal(ok, false);
assert.match(error, /positive integer/);
});
// ─── Timeout: the reason this runs in a worker at all ───────────────────────
test('an infinite loop is killed at the timeout instead of hanging', async () => {
// The core guarantee. NodeVM does not honor vm2's own `timeout`, so
// without the worker being terminated this call never returns.
const started = Date.now();
const { ok, result } = await exec({ code: 'while (true) {}', timeout: 1000 });
const elapsed = Date.now() - started;
assert.ok(ok, 'a timeout is a normal outcome, not a thrown error');
assert.equal(result.timedOut, true);
assert.ok(elapsed < 8000, `expected a prompt kill, took ${elapsed} ms`);
assert.match(result.note, /timeout/);
});
test('the worker-level hard kill also stops a blocking loop', async () => {
const report = await runInWorker('while (true) {}', { cap: 3000 });
assert.equal(report.timedOut, true);
});
// ─── Isolation ──────────────────────────────────────────────────────────────
test('built-in host modules are not reachable', async () => {
for (const mod of ['fs', 'child_process', 'os', 'node:fs']) {
const { ok, error } = await exec({ code: `require(${JSON.stringify(mod)});` });
assert.equal(ok, false, `${mod} must not be requireable`);
assert.match(error, /Cannot find module/);
}
});
test('node_modules packages are not reachable', async () => {
const { ok, error } = await exec({ code: 'require("chalk");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('vm2 itself is not reachable (no nesting escape)', async () => {
const { ok, error } = await exec({ code: 'require("vm2");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('fetch is absent unless network access was granted', async () => {
const offline = await runInWorker('module.exports = typeof fetch;');
assert.equal(offline.result, 'undefined');
});
// ─── Async ──────────────────────────────────────────────────────────────────
test('a promise assigned to module.exports is awaited', async () => {
const { ok, result } = await exec({
code: 'async function f() { return 41 + 1; }\nmodule.exports = f();',
});
assert.ok(ok);
assert.equal(result.result, 42);
});
test('a rejected promise is reported as an error', async () => {
const { ok, error } = await exec({
code: 'module.exports = (async () => { throw new Error("async boom"); })();',
});
assert.equal(ok, false);
assert.match(error, /async boom/);
});
test('a promise that never settles is bounded by the timeout', async () => {
const report = await runInWorker('module.exports = new Promise(() => {});', {
timeout: 800,
cap: 6000,
});
assert.match(String(report.error ?? ''), /did not settle/);
});

View File

@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
import { createElement as h } from 'react';
import { renderToString } from 'ink';
import { createTurnHandlers, dispatchTurnEvent } from '../src/repl.js';
import { createTurnHandlers, dispatchTurnEvent, stampUserInput } from '../src/repl.js';
import { TURN_EVENTS, TURN_STATES } from '../src/turn-state.js';
import { createStore } from '../src/ui/store.js';
import { App } from '../src/ui/app.js';
@@ -55,6 +55,21 @@ function playTurn(script) {
return { store, observed };
}
test('a user message is prefixed with a zero-padded 24-hour local time', () => {
const at = (h, m) => new Date(2026, 8, 17, h, m);
assert.equal(stampUserInput('add a test', at(14, 32)), '[14:32] add a test');
assert.equal(stampUserInput('list dirs', at(9, 5)), '[09:05] list dirs');
assert.equal(stampUserInput('midnight', at(0, 0)), '[00:00] midnight');
assert.equal(stampUserInput('last minute', at(23, 59)), '[23:59] last minute');
});
test('the timestamp prefix never touches the text it wraps', () => {
const at = new Date(2026, 8, 17, 8, 7);
const text = ' run websearch with start_date \n and a second line ';
assert.equal(stampUserInput(text, at), `[08:07] ${text}`);
assert.equal(stampUserInput('', at), '[08:07] ');
});
test('a plain answer reads as connecting -> streaming', () => {
const { observed } = playTurn([
{ cb: 'onContent' },