fix(tool-cordis): replace the pass-through ctx proxy with a whitelist façade
Review finding (#220): the guarded proxy only special-cased ctx.tools, so mount code could reach an UNGUARDED context through ctx.root, ctx.extend(), or a service instance's .ctx, then ctx.root.tools.register({…}) to bypass the marker check and host-realm normalization — a raw vm-realm result would later error a real agent turn at the session-log plainness check. The sandbox ctx is now a whitelist façade, not a pass-through proxy: it exposes only what a mount needs — tools.register (marker-guarded), on/once, provide, the timer helpers, and injected services resolved through a guarded get — and denies every framework-plumbing member (root, parent, fiber, reflect, registry, extend, isolate, intercept, plugin, set, mixin, …) with a teaching error. Injected services are wrapped so a method returning a Context is rejected on the way back (the .ctx escape), closing the one indirect leak. There is no context-valued member left to reach; cross-mount provide/inject is untouched (the plugin's own inject and the fiber's pending/active gating are unchanged). ctx.plugin (child plugins) and ctx.set are denied by design; ctx.effect is deferred (FIXME). Adds tests/sandbox-context.spec.ts covering the escape class (root/extend/fiber/ plugin/set/… denied, the classic root.tools.register bypass, the .ctx escape, read-only writes) plus the async-service and symbol/in-operator paths for 100% coverage. RFC/README/tool-catalog/config-catalog updated; api-catalog.ts regenerated (also picks up the codeRuntime service that entered on the master merge and was left stale).
This commit is contained in:
@@ -686,7 +686,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts:49`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts:53`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme
|
||||
|
||||
The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again.
|
||||
|
||||
The trust stance, stated once and threaded through the rest: the `node:vm` sandbox isolates the global context only — it prevents accidental global pollution, not malice. The `ctx` handed to a mounted plugin's `apply` is the real, fully privileged runtime handle; handing the model that handle is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's `cordis.yml`, never a product default.
|
||||
The trust stance, stated once and threaded through the rest: the `node:vm` sandbox isolates the global context only — it prevents accidental global pollution, not malice — and the `ctx` a mounted plugin's `apply` receives is a whitelist façade that narrows the *surface* (framework internals withheld) but not the *privilege* of what it exposes. The verbs the façade does expose reach the real runtime: a mounted tool can shell out through `ctx.bash`, read the filesystem through `ctx.fs`, reach the network through `ctx.web`. Neither the sandbox nor the façade is a security boundary; handing the model this power is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's `cordis.yml`, never a product default.
|
||||
|
||||
### The three tools
|
||||
|
||||
@@ -30,7 +30,7 @@ Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of
|
||||
|
||||
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
|
||||
|
||||
Three boundary mechanisms make model-written code behave correctly across the realm seam. **Dual-realm `instanceof`**: most objects sandbox code touches are host-realm (tool `args`, event payloads, service returns), so a plain `x instanceof Array` in the vm would silently be false — a per-sandbox prelude gives the vm realm's own constructors a `Symbol.hasInstance` that checks both the vm constructor and its host counterpart, patching only vm-realm globals. **Realm normalization of tool results**: objects built inside the vm carry the vm realm's `Object.prototype`, which the session log's append-time plainness check (`isJsonValue` in `dsh-session`, a prototype-identity comparison) rejects, so the sandbox's `harness.defineTool` JSON round-trips every `execute` return into the host realm — which also projects it onto exactly what the log durably stores. **Guarded registration**: the `ctx` a mounted plugin receives is a proxy whose `tools.register` accepts only definitions returned by `harness.defineTool` (a marker symbol), so every dynamic tool passes SchemaSpec validation and realm normalization; everything else on `ctx` passes through with correct `this` binding, which is what keeps cross-mount `provide`/`inject` working.
|
||||
Three boundary mechanisms make model-written code behave correctly across the realm seam. **Dual-realm `instanceof`**: most objects sandbox code touches are host-realm (tool `args`, event payloads, service returns), so a plain `x instanceof Array` in the vm would silently be false — a per-sandbox prelude gives the vm realm's own constructors a `Symbol.hasInstance` that checks both the vm constructor and its host counterpart, patching only vm-realm globals. **Realm normalization of tool results**: objects built inside the vm carry the vm realm's `Object.prototype`, which the session log's append-time plainness check (`isJsonValue` in `dsh-session`, a prototype-identity comparison) rejects, so the sandbox's `harness.defineTool` JSON round-trips every `execute` return into the host realm — which also projects it onto exactly what the log durably stores. **A whitelist context façade**: the `ctx` a mounted plugin's `apply` receives is NOT the real context nor a pass-through proxy over it — it is a façade exposing only what a mount legitimately needs (`tools.register` marker-guarded, `on`/`once`, `provide`, the timer helpers, and injected services resolved through a guarded `get`), with every framework-plumbing member (`root`, `parent`, `fiber`, `reflect`, `registry`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) denied with a teaching error. This closes an escape *class* rather than a single hole: a proxy that merely special-cased `ctx.tools` still handed back the raw context through `ctx.root`, `ctx.extend()`, or a service instance's `.ctx`, and mount code could then `ctx.root.tools.register({…})` to bypass the marker check and realm normalization — a raw vm-realm result then errors a real agent turn at the plainness check. The façade has no context-valued member to reach, and the one indirect leak (an injected-service method returning a `Context`) is rejected on the way back to sandbox code; cross-mount `provide`/`inject` keeps working because the plugin's own `inject` and the fiber's pending/active gating are untouched — only the `apply`-time `ctx` surface is narrowed.
|
||||
|
||||
Boundary errors are written around the mistakes models actually make (see [Consequences](#consequences) for how each was found), and the boundary normalizes rather than lectures wherever the input has exactly one meaning: schema `parameters` accept the JSON-Schema dialect models write by strong prior — the `{ type: 'object', properties, required: […] }` wrapper unwraps to the SchemaSpec DSL (the `required` array becoming per-property flags, at any nesting level), `type: 'integer'` maps to `number`, and `required: false` reads as optional — while genuinely meaningless input is rejected with the vocabulary enumerated (an unknown type lists the five valid ones; a non-boolean `required` names the rule). The remaining teaching errors: an unbalanced `});` closing gets the vm's offending source line plus a "code is a function body" reminder; TypeScript syntax gets the remove-annotations fix (detected on the failing line only, so an ` as ` inside a description string does not misfire); a forgotten `return` gets the two valid plugin forms; a Node built-in call gets the redirect to its cordis service; a tool-name collision on re-mount gets the unmount-first-then-remount recipe.
|
||||
|
||||
@@ -40,7 +40,7 @@ Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself
|
||||
|
||||
### Cross-mount composition via provide/inject
|
||||
|
||||
Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls `ctx.provide('foo', value)`, mount B declares `inject: ['foo']` and activates the moment `foo` exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's `apply` through the same guarded context; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
|
||||
Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls `ctx.provide('foo', value)`, mount B declares `inject: ['foo']` and activates the moment `foo` exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's `apply` through a fresh sandbox façade; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
|
||||
|
||||
### The generated API catalog
|
||||
|
||||
@@ -73,7 +73,7 @@ The correctness investment therefore goes where it pays for every capability at
|
||||
|
||||
**A new `cordis/mount` session event.** A durable provenance event recording each mount (source, name) has clear precedent (`hook/invoked`, `compact/start`). It was declined for v1: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a request-header delta, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs mount provenance separable from the tool call.
|
||||
|
||||
**A hardened / capability-restricted sandbox.** Trapping Node built-ins might suggest an intent to sandbox for safety. It is explicitly not that: the traps redirect the model toward cordis services (and away from leak-prone Node timers) for correctness and inspectability, but `ctx` is fully privileged and the vm is not a security boundary. A real security boundary (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
|
||||
**A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *surface* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.bash`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor
|
||||
|
||||
### `cordis_mount`
|
||||
|
||||
Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — cannot declare inject, uses whatever services are on the parent context, and accessing a service without inject (e.g. ctx.bash) throws; use it only when you need no injected services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form for any plugin that needs bash, llm, sessions, etc. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. 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`. 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()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) The sandbox prevents accidental global pollution, not malice: `ctx` is the real, fully privileged runtime handle.
|
||||
Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — cannot declare inject, uses whatever services are on the parent context, and accessing a service without inject (e.g. ctx.bash) throws; use it only when you need no injected services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form for any plugin that needs bash, llm, sessions, etc. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. 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`. 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()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. The `ctx` a mounted plugin's `apply` receives is the real, fully privileged runtime handle; load this plugin as deliberately as you would grant a bash tool.
|
||||
The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. The `ctx` a mounted plugin's `apply` receives is a whitelist façade — register tools, observe events, provide/consume services, use timers; framework internals (`ctx.root`, `ctx.fiber`, `ctx.extend`, `ctx.plugin`, …) are withheld — but the capabilities it does expose reach the real runtime, so load this plugin as deliberately as you would grant a bash tool.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'onTaskDone(listener: BashTaskListener): () => void',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
summary: 'Abstract code-execution service.',
|
||||
methods: [
|
||||
'abstract run(request: CodeRunRequest): Promise<CodeRunResult>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'compact',
|
||||
summary: 'Abstract compaction service.',
|
||||
@@ -429,6 +436,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CallId',
|
||||
declaration: 'export type CallId = Branded<\'CallId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingFunction',
|
||||
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<unknown>;',
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingNamespace',
|
||||
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeLogEntry',
|
||||
declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeRunFailure',
|
||||
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeRunRequest',
|
||||
declaration: 'export interface CodeRunRequest {\n program: string;\n bindings: CodeBindingNamespace[];\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeRunResult',
|
||||
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CollectedOutput',
|
||||
declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}',
|
||||
|
||||
@@ -2,18 +2,35 @@
|
||||
* The registration boundary between sandboxed mount code and the real runtime:
|
||||
* SchemaSpec normalization + validation with teaching errors, the
|
||||
* marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
|
||||
* guarded `ctx` proxy a mounted plugin receives, and the plugin-shape helpers
|
||||
* the mount lifecycle narrows sandbox return values with.
|
||||
* SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
|
||||
* real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||
* return values with.
|
||||
*
|
||||
* Two realm facts drive the design. Objects built inside the vm carry the vm
|
||||
* The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
|
||||
* exactly four things — register a tool, listen to an event, provide a service,
|
||||
* call an injected service (timers included) — so the façade exposes only those
|
||||
* verbs and the injected services, each individually wrapped. Every framework
|
||||
* plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
|
||||
* `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
|
||||
* DENIED with a teaching error rather than passed through. This closes an
|
||||
* entire escape class at once: a pass-through proxy that only special-cased
|
||||
* `ctx.tools` still handed back the raw context through `ctx.root`,
|
||||
* `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
|
||||
* `ctx.root.tools.register({…})` to bypass the marker check and host-realm
|
||||
* normalization — a raw vm-realm result then errors a real agent turn at the
|
||||
* session-log plainness check. The whitelist has no such hole: there is no
|
||||
* context-valued member to reach, and any injected-service method that returns
|
||||
* a `Context` is rejected (harness services never do — see {@link denyContext}).
|
||||
*
|
||||
* Two realm facts drive the tool path. Objects built inside the vm carry the vm
|
||||
* realm's `Object.prototype`, and the session log's append-time plainness check
|
||||
* (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
|
||||
* foreign-realm data — so every dynamic tool's `execute` return is JSON
|
||||
* round-tripped into the host realm before it reaches the registry, and the
|
||||
* schema itself is rebuilt as fresh host-realm objects. And a malformed tool
|
||||
* schema must fail at REGISTRATION, not when a later request assembles it — so
|
||||
* dynamic `ctx.tools.register` calls accept only definitions produced by the
|
||||
* sandbox's `harness.defineTool`, which normalizes `parameters` up front.
|
||||
* dynamic tool registration accepts only definitions produced by the sandbox's
|
||||
* `harness.defineTool`, which normalizes `parameters` up front.
|
||||
*
|
||||
* Normalize, don't lecture, where the input has exactly one meaning: models
|
||||
* write the JSON-Schema dialect by strong prior (the `{ type: 'object',
|
||||
@@ -26,7 +43,8 @@
|
||||
* @module @deepseek-ai/dsh-tool-cordis/guard
|
||||
*/
|
||||
|
||||
import type { Context, Plugin } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import type { Plugin } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -153,31 +171,116 @@ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||
return ctx.tools.register(tool)
|
||||
}
|
||||
|
||||
function bindMethod(value: unknown, target: object): unknown {
|
||||
if (typeof value !== 'function') return value
|
||||
return (...args: unknown[]): unknown => Reflect.apply(value, target, args) as unknown
|
||||
/**
|
||||
* The verbs a mounted plugin may reach through the sandbox `ctx` façade,
|
||||
* beyond its injected services. `on`/`once` observe events, `provide` exposes
|
||||
* a service to other mounts, and the timer helpers schedule work — each a
|
||||
* fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
|
||||
* is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
|
||||
* mixin accessors that throw `without inject` when read on a plugin that did
|
||||
* not inject `timer`, so the façade reads `ctx[verb]` only at call time — the
|
||||
* plugin that never touches a timer never trips that, and one that does gets
|
||||
* cordis's own inject error at the call site.
|
||||
*/
|
||||
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
|
||||
|
||||
/**
|
||||
* The tool-registry façade: only `register` (marker-guarded), plus the
|
||||
* read-only `schemas` / `get` a mount may legitimately want. No other registry
|
||||
* method (nothing that could re-enter the raw context) is exposed.
|
||||
*/
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
return {
|
||||
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(),
|
||||
get: (name: string) => ctx.tools.get(name),
|
||||
}
|
||||
}
|
||||
|
||||
function guardedContext(ctx: Context): Context {
|
||||
const tools = new Proxy(ctx.tools, {
|
||||
/**
|
||||
* Reject any injected-service return that is a cordis `Context`. Harness
|
||||
* services return data, never a context; a value that is one would be a
|
||||
* fresh, unguarded handle back into the runtime — the exact escape the façade
|
||||
* exists to close — so it fails loud instead of reaching sandbox code.
|
||||
*/
|
||||
function denyContext(value: unknown, service: string): unknown {
|
||||
if (value instanceof Context) {
|
||||
throw new Error(
|
||||
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
|
||||
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
|
||||
+ 'and the services you inject — never another context.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an injected service so its methods forward to the real instance but
|
||||
* their return values pass through {@link denyContext}. Non-function members
|
||||
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
|
||||
*/
|
||||
function guardedService(service: object, name: string): unknown {
|
||||
return new Proxy(service, {
|
||||
get(target, prop) {
|
||||
if (prop === 'register') {
|
||||
return (tool: unknown): () => void => sandboxRegisterTool(ctx, tool)
|
||||
}
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
return bindMethod(value, target)
|
||||
if (typeof value !== 'function') return denyContext(value, name)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(v => denyContext(v, name))
|
||||
return denyContext(result, name)
|
||||
}
|
||||
},
|
||||
})
|
||||
return new Proxy(ctx, {
|
||||
get(target, prop) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox context façade handed to a mounted plugin's `apply` in place of
|
||||
* the real `ctx`. A whitelist (see the module doc): the registration/eventing
|
||||
* verbs, the timer helpers, a guarded `tools`, and injected services resolved
|
||||
* through a guarded `get` / property access. Every framework-plumbing member
|
||||
* is denied with a teaching error; there is no context-valued member to reach.
|
||||
*/
|
||||
function sandboxContext(ctx: Context): Context {
|
||||
const tools = sandboxTools(ctx)
|
||||
// Resolve a named service to a guarded wrapper, or undefined when absent.
|
||||
const resolveService = (name: string): unknown => {
|
||||
if (name === 'tools') return tools
|
||||
const service: unknown = ctx.get(name)
|
||||
return service === undefined ? undefined : guardedService(service as object, name)
|
||||
}
|
||||
const get = (name: string): unknown => resolveService(name)
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'tools') return tools
|
||||
if (prop === 'get') {
|
||||
return (service: string): unknown => service === 'tools' ? tools : target.get(service)
|
||||
if (prop === 'get') return get
|
||||
if (typeof prop !== 'string') return undefined
|
||||
// Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
|
||||
// that never uses a timer never triggers the timer mixin's inject check.
|
||||
if (CTX_VERBS.has(prop)) {
|
||||
return (...args: unknown[]): unknown => {
|
||||
const method = ctx[prop as keyof Context]
|
||||
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
|
||||
}
|
||||
}
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
return bindMethod(value, target)
|
||||
// A declared-and-injected service reads as a ctx property; resolve it
|
||||
// through the same guard. Absent → the deny path (framework plumbing,
|
||||
// an un-injected service, or a typo) with one teaching error.
|
||||
const service = resolveService(prop)
|
||||
if (service !== undefined) return service
|
||||
throw new Error(
|
||||
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
|
||||
+ 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
|
||||
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
|
||||
)
|
||||
},
|
||||
})
|
||||
// A façade is not the real ctx; block writes rather than let mount code
|
||||
// stash state on a throwaway object and think it persisted.
|
||||
set(_target, prop) {
|
||||
throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
|
||||
},
|
||||
has: (_target, prop) => prop === 'tools' || prop === 'get'
|
||||
|| (typeof prop === 'string' && (CTX_VERBS.has(prop) || resolveService(prop) !== undefined)),
|
||||
}) as unknown as Context
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,13 +297,19 @@ export function isPlugin(value: unknown): value is Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin so its `apply` receives a guarded context (`tools.register`
|
||||
* only accepts tools from `harness.defineTool`). Both function-form and
|
||||
* object-form plugins go through the same guard; everything else on the
|
||||
* context — `on`, `provide`, `inject` resolution — passes through with correct
|
||||
* `this` binding, so cross-mount provide/inject works unmodified.
|
||||
* Wrap a plugin so its `apply` receives the sandbox context façade instead of
|
||||
* the real `ctx` (see {@link sandboxContext} and the module doc). Both
|
||||
* function-form and object-form plugins go through the same wrap; the plugin's
|
||||
* own `inject` declaration is preserved (cordis reads it from the plugin
|
||||
* object, and pending/active gating happens on the real fiber before `apply`
|
||||
* runs), so cross-mount provide/inject works unmodified.
|
||||
*
|
||||
* `ctx.effect(customCleanup)` is deliberately absent from the façade for now —
|
||||
* `on` / `provide` / `tools.register` cover every mount seen so far, and each
|
||||
* is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
|
||||
* once a real mount needs a bespoke disposer.
|
||||
* @param plugin - the plugin the mount code returned.
|
||||
* @returns an equivalent plugin whose `apply` sees the guarded context.
|
||||
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
|
||||
*/
|
||||
export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
if (typeof plugin === 'function') {
|
||||
@@ -208,7 +317,7 @@ export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
return {
|
||||
name: pluginName(plugin),
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return functionPlugin(guardedContext(ctx), config)
|
||||
return functionPlugin(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -216,7 +325,7 @@ export function guardedPlugin(plugin: Plugin): Plugin {
|
||||
return {
|
||||
...plugin,
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return objectPlugin.apply(guardedContext(ctx), config)
|
||||
return objectPlugin.apply(sandboxContext(ctx), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
* this plugin. Design home:
|
||||
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
|
||||
*
|
||||
* The vm sandbox guards against ACCIDENTAL global pollution only — it is not a
|
||||
* security boundary. The `ctx` handed to the mounted plugin's `apply` is the
|
||||
* real, fully privileged runtime handle; that is the point of the toolset, so
|
||||
* a deployment loads this plugin as deliberately as it grants a bash tool.
|
||||
* The vm sandbox guards against ACCIDENTAL global pollution only, and the `ctx`
|
||||
* a mounted plugin's `apply` receives is a WHITELIST façade (register a tool,
|
||||
* observe events, provide/consume services, use timers — framework internals
|
||||
* withheld; see the guard module). Neither is a security boundary: the verbs
|
||||
* the façade DOES expose reach the real runtime unsandboxed (a mounted tool can
|
||||
* shell out through `ctx.bash`), so a deployment loads this plugin as
|
||||
* deliberately as it grants a bash tool. Design home:
|
||||
* docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
|
||||
*
|
||||
* Plugin export shape: named exports, NO default. The cordis Loader's
|
||||
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
|
||||
@@ -159,8 +163,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
+ 'VETOES the call; prefer plain notification events unless you intend to '
|
||||
+ 'intercept. (2) Never await something that only resolves after the current '
|
||||
+ 'turn (your code runs INSIDE a tool call of that turn — it would deadlock). '
|
||||
+ '(3) The sandbox prevents accidental global pollution, not malice: `ctx` is '
|
||||
+ 'the real, fully privileged runtime handle.',
|
||||
+ '(3) Your `ctx` is a restricted façade: you can register tools, observe '
|
||||
+ 'events, provide/consume services, and use timers, but framework internals '
|
||||
+ '(ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a '
|
||||
+ 'security boundary though — the services you inject (e.g. ctx.bash) reach the '
|
||||
+ 'real runtime.',
|
||||
parameters: {
|
||||
code: {
|
||||
type: 'string',
|
||||
|
||||
@@ -386,13 +386,13 @@ describe('cordis_mount', () => {
|
||||
console.error('errored')
|
||||
const round = atob(btoa('hi'))
|
||||
const bytes = new TextEncoder().encode(round)
|
||||
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.fiber) } }
|
||||
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.on) } }
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plugin "codec-hi"')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'warned')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'object')
|
||||
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'applied', 'function')
|
||||
expect(error).toHaveBeenCalledWith('[cordis:dyn-1]', 'errored')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The sandbox context façade is a whitelist, not a pass-through proxy: mount
|
||||
* code reaches only the registration/eventing verbs, the timer helpers, a
|
||||
* guarded `tools`, and its injected services. Every framework-plumbing member
|
||||
* that could hand back an UNGUARDED context — through which a plugin could
|
||||
* `ctx.<escape>.tools.register({…})` to bypass the marker check and host-realm
|
||||
* normalization — is denied. These are the regression guards for that escape
|
||||
* class (the review finding on the original pass-through proxy).
|
||||
*/
|
||||
|
||||
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
|
||||
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
return text(result)
|
||||
}
|
||||
|
||||
describe('sandbox context façade — escape surface is closed', () => {
|
||||
it.each([
|
||||
['ctx.root', 'const c = ctx.root'],
|
||||
['ctx.parent', 'const c = ctx.parent'],
|
||||
['ctx.scope', 'const c = ctx.scope'],
|
||||
['ctx.fiber', 'const f = ctx.fiber'],
|
||||
['ctx.reflect', 'const r = ctx.reflect'],
|
||||
['ctx.registry', 'const r = ctx.registry'],
|
||||
['ctx.events', 'const e = ctx.events'],
|
||||
['ctx.extend()', 'ctx.extend({})'],
|
||||
['ctx.isolate()', 'ctx.isolate("x")'],
|
||||
['ctx.intercept()', 'ctx.intercept("x", {})'],
|
||||
['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
|
||||
['ctx.set()', 'ctx.set("tools", 1)'],
|
||||
['ctx.mixin()', 'ctx.mixin("x", [])'],
|
||||
])('denies %s with a teaching error', async (_label, expr) => {
|
||||
const ctx = await setup()
|
||||
const message = await mountTouching(ctx, expr)
|
||||
expect(message).toContain('sandbox ctx does not expose')
|
||||
expect(message).toContain('withheld by design')
|
||||
})
|
||||
|
||||
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'root-bypass',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
ctx.root.tools.register({
|
||||
name: 'smuggled',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx does not expose "root"')
|
||||
// The whole point: the bypass never reaches the registry.
|
||||
expect(ctx.tools.get('smuggled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects assignment to the façade rather than silently dropping it', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('sandbox ctx is read-only')
|
||||
})
|
||||
|
||||
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
|
||||
// A cordis Service instance carries `.ctx` (a real Context), so
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded
|
||||
// handle. The service wrapper's return-value guard rejects any Context on
|
||||
// the way back to sandbox code, so the escape never lands. (`systemPrompt`
|
||||
// is in the setup harness, so the plugin activates and its apply runs.)
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'svc-ctx-escape',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) {
|
||||
ctx.systemPrompt.ctx.root.tools.register({
|
||||
name: 'smuggled_via_service',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('returned a cordis Context, which the sandbox does not expose')
|
||||
expect(ctx.tools.get('smuggled_via_service')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise
|
||||
// (a vm-realm one is not `instanceof` the host `Promise`). Provide a
|
||||
// host-realm service from the test, then inject + await it from a mount:
|
||||
// the resolved value is non-Context data and passes through.
|
||||
const ctx = await setup()
|
||||
ctx.plugin({
|
||||
name: 'host-async-svc',
|
||||
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
|
||||
})
|
||||
await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'async-consumer',
|
||||
inject: ['hostAsync', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'do_fetch',
|
||||
description: 'awaits the host async service',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
const value = await ctx.hostAsync.grab()
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
const result = await call(ctx, 'do_fetch', {})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('host-fetched')
|
||||
})
|
||||
|
||||
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'cordis_mount', {
|
||||
code: `
|
||||
return {
|
||||
name: 'introspector',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
const sym = ctx[Symbol.iterator]
|
||||
console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
|
||||
},
|
||||
}
|
||||
`,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user