feat(tool-cordis): Node-API traps + fs/web capability routing
The sandbox deliberately provides no Node API, and now says so instead of letting a bare ReferenceError teach nothing: require, the timers, and fetch are callable traps whose error redirects to the cordis alternative (inject: ['fs'] + ctx.fs, ['web'] + ctx.web, ['bash'] + ctx.bash, ['timer'] + ctx.setTimeout — a fiber effect, unwound on unmount). Only function-shaped globals are trapped; process/Buffer stay undefined so typeof feature probes stay inert. The mount description and the demo persona state the routing rule, and the demo mounts ctx.fs (local provider) and ctx.web (seam + keyless local fetch provider) so agent-built plugins have real capabilities to build on. Live-validated: a model that reached for Node setTimeout self-corrected to inject: ['timer'] in one step and built a working ctx.web fetch tool.
This commit is contained in:
@@ -152,8 +152,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
+ 'Everything registered inside `apply` is cleaned up automatically on unmount. '
|
||||
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
|
||||
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
|
||||
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`; '
|
||||
+ 'there is no `require`, `process`, `Buffer`, or network. '
|
||||
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
|
||||
+ 'Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, '
|
||||
+ 'never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect '
|
||||
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
|
||||
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
|
||||
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
|
||||
+ 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
|
||||
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
|
||||
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
|
||||
+ 'trailing `next` callback which MUST be called — returning without `next()` '
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose
|
||||
* globals are a tagged write-through console, the `harness` registration
|
||||
* helpers, and the encoding primitives a bare vm context lacks. The sandbox
|
||||
* guards against ACCIDENTAL global pollution only — it is not a security
|
||||
* boundary; the `ctx` a mounted plugin's `apply` later receives is the real,
|
||||
* fully privileged runtime handle, and that is the point of the toolset.
|
||||
* helpers, the encoding primitives a bare vm context lacks, and callable traps
|
||||
* over the Node APIs the sandbox deliberately withholds. Capability access is
|
||||
* routed through cordis services, never Node built-ins: filesystem work goes
|
||||
* through `ctx.fs`, network through `ctx.web`, processes through `ctx.bash`,
|
||||
* timers through the `ctx.timer` helpers (fiber effects, unwound on unmount)
|
||||
* — so everything a mounted plugin does stays inspectable and disposable. The
|
||||
* sandbox guards against ACCIDENTAL global pollution only — it is not a
|
||||
* security boundary; the `ctx` a mounted plugin's `apply` later receives is
|
||||
* the real, fully privileged runtime handle, and that is the point of the
|
||||
* toolset.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/sandbox
|
||||
*/
|
||||
@@ -60,19 +66,58 @@ function patchDualRealmInstanceof(sandbox: object): void {
|
||||
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
|
||||
}
|
||||
|
||||
const TIMER_REDIRECT
|
||||
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
|
||||
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.'
|
||||
|
||||
/**
|
||||
* The callable Node APIs the sandbox deliberately disables, each mapped to the
|
||||
* cordis alternative its trap error names. Only FUNCTION-shaped globals are
|
||||
* trapped — a data-shaped global like `process` stays `undefined`, because a
|
||||
* throwing accessor would detonate the common `typeof process` feature probe
|
||||
* at resolution time.
|
||||
*/
|
||||
const NODE_API_REDIRECTS: Record<string, string> = {
|
||||
require:
|
||||
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
|
||||
+ '[\'web\'] for HTTP, [\'bash\'] for processes; cordis_inspect what:"api" lists what THIS runtime provides.',
|
||||
setTimeout: TIMER_REDIRECT,
|
||||
setInterval: TIMER_REDIRECT,
|
||||
setImmediate: TIMER_REDIRECT,
|
||||
clearTimeout: TIMER_REDIRECT,
|
||||
clearInterval: TIMER_REDIRECT,
|
||||
fetch:
|
||||
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
|
||||
+ '(see cordis_inspect what:"api" for its methods).',
|
||||
}
|
||||
|
||||
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
|
||||
function nodeApiTraps(): Record<string, () => never> {
|
||||
const traps: Record<string, () => never> = {}
|
||||
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
|
||||
traps[name] = () => {
|
||||
throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
|
||||
}
|
||||
}
|
||||
return traps
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the vm context one `cordis_mount` call evaluates in: the tagged
|
||||
* console, the `harness` registration helpers, the encoding primitives, and
|
||||
* the dual-realm `instanceof` patch, already `createContext`-ed.
|
||||
* console, the `harness` registration helpers, the encoding primitives, the
|
||||
* Node-API traps, and the dual-realm `instanceof` patch, already
|
||||
* `createContext`-ed.
|
||||
* @param id - the mount id (`dyn-<n>`), used as the console tag and filename stem.
|
||||
* @returns the contextified sandbox object to pass to {@link evaluateMountCode}.
|
||||
*/
|
||||
export function createSandbox(id: string): object {
|
||||
const sandbox = {
|
||||
...nodeApiTraps(),
|
||||
console: taggedConsole(id),
|
||||
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool },
|
||||
// Web APIs absent from fresh vm contexts — made available so the model
|
||||
// can encode/decode base64 without Buffer (which is also absent).
|
||||
// can encode/decode base64 without Buffer (which is also absent). Host
|
||||
// closures over Buffer, never Buffer itself.
|
||||
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
|
||||
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
|
||||
TextEncoder,
|
||||
|
||||
@@ -284,12 +284,12 @@ describe('cordis_mount', () => {
|
||||
expect(retry.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates sandbox globals: no process/require, and globalThis writes do not leak to the host', async () => {
|
||||
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
globalThis.__cordis_tool_leak = 'leaked'
|
||||
return { name: 'probe-' + typeof process + '-' + typeof require, apply(ctx) {} }
|
||||
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -297,6 +297,39 @@ describe('cordis_mount', () => {
|
||||
expect((globalThis as Record<string, unknown>).__cordis_tool_leak).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'],
|
||||
['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'],
|
||||
['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'],
|
||||
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(trapMessage)
|
||||
expect(text(result)).toContain(redirect)
|
||||
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
|
||||
})
|
||||
|
||||
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'ticker',
|
||||
inject: ['timer'],
|
||||
apply(ctx) {
|
||||
ctx.setTimeout(() => console.log('tick'), 10)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('state: active')
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
|
||||
})
|
||||
|
||||
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
|
||||
const ctx = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
Reference in New Issue
Block a user