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:
imccyu
2026-07-08 11:51:24 +08:00
parent e51e58e993
commit db45769513
8 changed files with 129 additions and 18 deletions

View File

@@ -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()` '

View File

@@ -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,

View File

@@ -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(() => {})