Просмотр исходного кода

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).
imccyu 2 месяцев назад
Родитель
Сommit
1b1ba96d4f

+ 1 - 1
docs/config-catalog.md

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

Разница между файлами не показана из-за своего большого размера
+ 1 - 2
docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
docs/tool-catalog.md


+ 1 - 1
packages/cordis/tool-cordis/README.md

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

+ 31 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

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

+ 139 - 30
packages/cordis/tool-cordis/src/guard.ts

@@ -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),
+  }
+}
+
+/**
+ * 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
 }
 
-function guardedContext(ctx: Context): Context {
-  const tools = new Proxy(ctx.tools, {
+/**
+ * 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)
     },
   }
 }

+ 13 - 6
packages/cordis/tool-cordis/src/index.ts

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

+ 2 - 2
packages/cordis/tool-cordis/tests/mount.spec.ts

@@ -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')
   })
 

+ 156 - 0
packages/cordis/tool-cordis/tests/sandbox-context.spec.ts

@@ -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)
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов