diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml index a5273c9e3d..16852c1004 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-unified-json-value-schema-dsl.md: 3e35bce6eb48afeb31c564e9b5d7b84ff91a7b1f -2026-07-20-unified-json-value-schema-dsl.zh.md: 77a20d17aab61e759de6e490510b14b5bc408726 +2026-07-20-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9 +2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md index 3e35bce6eb..09945c413f 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -16,7 +16,7 @@ An explicit author object must declare `additionalProperties: true | false`. The `InferValue` and `InferArgs

` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. Exact inference is bounded to 16 container levels and then uses `JsonValue`, preventing TypeScript's type-instantiation stack from becoming the authoring limit. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack. -Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. +Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. The dynamic boundary rejects JSON-invisible record keys and exotic schema arrays before normalization, so it cannot silently discard a constraint or consume custom iteration semantics. ## Alternatives considered @@ -32,4 +32,4 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent - Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call. - Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth. - Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them. -- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, and deep nesting across core and dynamic projections. +- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md index 77a20d17aa..00a7a19961 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -16,7 +16,7 @@ Status: implemented `InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。 -对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。 +对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。 ## 备选方案 @@ -32,4 +32,4 @@ Status: implemented - 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。 - 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。 - 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 -- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导,以及核心投影和动态投影中的深层嵌套。 +- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 760213ce07..4659807508 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-code-mode-typed-tool-returns.md: 8411392dce9c70b1d652a339d8b45e66d51e6ec3 -2026-07-20-code-mode-typed-tool-returns.zh.md: c787255f63ef5bb1469843f650711b311ec17175 +2026-07-20-code-mode-typed-tool-returns.md: 31cdb6cc58d5e31b8be5a0d3e9cca51ed3e525aa +2026-07-20-code-mode-typed-tool-returns.zh.md: 190ae11a2bc5a55506679733cdd79745f64ba2ef diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 8411392dce..31cdb6cc58 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -53,7 +53,7 @@ Before dispatch the bridge snapshots binding arguments as lossless JSON and snap Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime seam treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. -Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. The dependency-light runtime seam names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful. +Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. The worker captures the native function-source intrinsic before program execution and uses it to distinguish realm-owned plain-container prototypes from user-authored constructors that imitate `Object` or `Array`. The dependency-light runtime seam names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful. ### Outer result and output ledger @@ -79,7 +79,7 @@ The opaque `exec.parent` token marks nested calls. Presentation metadata and gen ## Testing -Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. +Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its task id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `task_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index c787255f63..190ae11a2b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -53,7 +53,7 @@ declare const tools: { Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其 reject 异常能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。 -绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 +绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。worker 会在程序执行前捕获用于读取函数源码的原生内建方法,并据此区分每个 JavaScript 运行域原生的普通容器原型与由用户编写、冒充 `Object` 或 `Array` 的构造函数伪造的原型。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 ### 外层结果与输出账本 @@ -79,7 +79,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ## 测试 -编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 8175a403c9..a8e895aea1 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -23,7 +23,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at - **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys. - **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns. - **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`). -- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, flatten into a bounded-depth pre-order wire value for structured clone, and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits. +- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, including a native-constructor identity check captured before program execution so user-authored functions cannot impersonate plain-container prototypes. Values flatten into a bounded-depth pre-order wire value for structured clone and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits. - **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy. - **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags. - **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving. diff --git a/packages/code-runtime/code-runtime-worker/src/worker-json.ts b/packages/code-runtime/code-runtime-worker/src/worker-json.ts index 7ef4526321..1688365e9c 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker-json.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker-json.ts @@ -3,13 +3,27 @@ import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' /* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */ -/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */ +type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown + +const intrinsicFunctionToString = Reflect.get(Function.prototype, 'toString') as IntrinsicCallable +const intrinsicReflectApply = Reflect.get(Reflect, 'apply') as ( + target: IntrinsicCallable, + thisArgument: unknown, + argumentsList: readonly unknown[], +) => unknown + +/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') const constructor: unknown = descriptor?.value - return typeof constructor === 'function' - && constructor.name === name - && constructor.prototype === prototype + if (typeof constructor !== 'function') return false + try { + return constructor.name === name + && constructor.prototype === prototype + && intrinsicReflectApply(intrinsicFunctionToString, constructor, []) === `function ${name}() { [native code] }` + } catch { + return false + } } /** Whether a candidate is one realm's intrinsic `Object.prototype`. */ diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index a58675d1ed..3a2f5ffa69 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -623,6 +623,40 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { })) }) + it('rejects intrinsic-looking exotic objects as arguments and completions', async () => { + const { runtime } = await setup() + let calls = 0 + const forgeObject = ` + const prototype = Object.create(null); + const SpoofedObject = function Object() {}; + SpoofedObject.prototype = prototype; + Object.defineProperty(prototype, 'constructor', { value: SpoofedObject }); + const forged = Object.assign(Object.create(prototype), { value: 1 }); + Function.prototype.toString = () => 'function Object() { [native code] }'; + ` + const argument = await runtime.run({ + program: `${forgeObject} + try { await tools.never(forged) } catch (error) { + return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message }; + } + `, + bindings: tools({ never: async () => { calls += 1; return null } }), + }) + expect(calls).toBe(0) + expect(argument.value).toEqual({ + typed: true, + name: 'ToolCallError', + toolName: 'never', + message: 'binding arguments must be lossless JSON', + }) + + const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] }) + expect(completion).toEqual({ + logs: [], + error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' }, + }) + }) + it('rejects forged lossy binding arguments again at the host boundary', async () => { const { runtime } = await setup() let calls = 0 diff --git a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts index 012cc1204a..e0c8310a6a 100644 --- a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts @@ -97,6 +97,26 @@ describe('snapshotCodeJsonValue', () => { Object.setPrototypeOf(forgedPrototype, null) const forgedArray = [1] Object.setPrototypeOf(forgedArray, forgedPrototype) + const spoofedObjectPrototype = Object.create(null) as Record + const SpoofedObject = function Object() {} + SpoofedObject.prototype = spoofedObjectPrototype + Object.defineProperty(spoofedObjectPrototype, 'constructor', { value: SpoofedObject }) + const spoofedObject = Object.create(spoofedObjectPrototype) as Record + spoofedObject.value = 1 + const revokedPrototype = Object.create(null) as Record + const RevokedObject = function Object() {} + RevokedObject.prototype = revokedPrototype + const revokedConstructor = Proxy.revocable(RevokedObject, {}) + Object.defineProperty(revokedPrototype, 'constructor', { value: revokedConstructor.proxy }) + const revokedObject = Object.create(revokedPrototype) as Record + revokedConstructor.revoke() + const spoofedArrayPrototype: unknown[] = [] + Object.setPrototypeOf(spoofedArrayPrototype, Object.prototype) + const SpoofedArray = function Array() {} + SpoofedArray.prototype = spoofedArrayPrototype + Object.defineProperty(spoofedArrayPrototype, 'constructor', { value: SpoofedArray }) + const spoofedArray = [1] + Object.setPrototypeOf(spoofedArray, spoofedArrayPrototype) for (const value of [ new ExoticObject(), @@ -110,11 +130,16 @@ describe('snapshotCodeJsonValue', () => { symbolObject, customPrototypeObject, forgedArray, + spoofedObject, + revokedObject, + spoofedArray, cyclic, [undefined], { value: undefined }, ]) { - expect(snapshotCodeJsonValue(value)).toBeUndefined() + const canonical = snapshotJsonValue(value) + expect(canonical).toBeUndefined() + expect(snapshotCodeJsonValue(value)).toEqual(canonical) } }) diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 5a281c0466..aabc893808 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -14,7 +14,7 @@ Canonical successes are the inspection string, mount `{ id, pluginName, state, p ## Trust stance -The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Config diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index ba22b76c8f..b9bc992ad8 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -64,6 +64,24 @@ function hasPlainArrayPrototype(value: unknown[]): boolean { } /* jscpd:ignore-end */ +/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */ +function isDensePlainArray(value: unknown): value is unknown[] { + if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) { + return false + } + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) return false + } + return true +} + +/** Reject schema records whose declarations would disappear from object enumeration. */ +function assertSchemaContainerKeys(value: Record, path: string): void { + if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) { + throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`) + } +} + /** Where one cloned JSON value is installed. */ type CloneDestination = | { kind: 'root' } @@ -175,6 +193,7 @@ function copyAnnotations(value: Record, output: Record, path: string, allowed: readonly string[]): void { + assertSchemaContainerKeys(value, path) for (const key of Object.keys(value)) { if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`) } @@ -217,11 +236,16 @@ function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): { /** Validate raw required names and return their lookup set. */ function normalizeRequiredNames(value: unknown, properties: Record, path: string): Set { if (value === undefined) return new Set() - if (!Array.isArray(value) || value.some(name => typeof name !== 'string')) { + if (!isDensePlainArray(value)) { throw new Error(`harness.defineTool ${path} must be an array of declared property names`) } - const names = new Set(value as string[]) - for (const name of names) { + const names = new Set() + for (let index = 0; index < value.length; index++) { + const name = value[index] + if (typeof name !== 'string') { + throw new Error(`harness.defineTool ${path} must be an array of declared property names`) + } + names.add(name) if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`) } return names @@ -310,6 +334,7 @@ function normalizePropertyMap( } if (task.kind === 'map') { if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`) + assertSchemaContainerKeys(task.entries, task.path) ancestors.add(task.entries) const spec: Record = {} assignNormalizedMap(task.destination, spec) @@ -336,6 +361,7 @@ function normalizePropertyMap( if (!isPlainRecord(value)) { throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`) } + assertSchemaContainerKeys(value, path) if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`) ancestors.add(value) const requiredKey = task.parameterProperty && !task.raw ? ['required'] : [] @@ -353,7 +379,9 @@ function normalizePropertyMap( if (Object.hasOwn(value, 'oneOf')) { assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS]) - if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`) + if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) { + throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`) + } const oneOf: Record[] = [] prop.oneOf = oneOf for (let index = value.oneOf.length - 1; index >= 0; index--) { @@ -434,9 +462,10 @@ function normalizePropertyMap( case 'null': assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS]) if (Object.hasOwn(value, 'enum')) { - prop.enum = Array.isArray(value.enum) - ? Array.from(value.enum, (entry, index) => cloneJson(entry, `${path}.enum[${index}]`)) - : value.enum + if (!isDensePlainArray(value.enum) || value.enum.length === 0) { + throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`) + } + prop.enum = cloneJson(value.enum, `${path}.enum`) } if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`) break diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 4f361a1ef1..a354de0b37 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -419,7 +419,10 @@ describe('cordis_mount', () => { it.each([ ['parameters: 42', 'must be a ParameterSchemaSpec object'], + ['parameters: Object.defineProperty({}, \'text\', { value: { type: \'string\' } })', 'parameters must contain only own enumerable string keys'], ['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'], + ['parameters: { text: Object.defineProperty({ type: \'string\' }, \'minimum\', { value: 1 }) }', 'parameters.text must contain only own enumerable string keys'], + ['parameters: { text: { type: \'string\', [Symbol(\'hidden\')]: true } }', 'parameters.text must contain only own enumerable string keys'], ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'], ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'], ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'], @@ -430,6 +433,10 @@ describe('cordis_mount', () => { ['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'], ['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'], ['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: [42] }', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { const required = []; required.length = 1; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { const required = []; required.length = 1; required.extra = true; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { class Names extends Array { *[Symbol.iterator]() {} }; const required = new Names(); required[0] = \'text\'; required.length = 1; return { type: \'object\', properties: { text: { type: \'string\' } }, required } })()', 'parameters.required must be an array of declared property names'], ['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'], ['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'], ['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'], @@ -439,7 +446,10 @@ describe('cordis_mount', () => { ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'], ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'], ['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { oneOf: new (class Branches extends Array {})({ type: \'string\' }, { type: \'null\' }) } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { oneOf: Object.assign([{ type: \'string\' }, { type: \'null\' }], { extra: true }) } }', 'parameters.value.oneOf must contain at least two schemas'], ['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'], + ['parameters: { value: { type: \'string\', enum: new (class Values extends Array {})(\'a\', \'b\') } }', 'parameters.value.enum must be a non-empty array'], ['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'], ['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'], ['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],