@deepseek-ai/dsh-cordis-host-runner 0.2.2-alpha.3 → 0.2.2-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/guard.ts ADDED
@@ -0,0 +1,836 @@
1
+ /**
2
+ * The registration boundary between a sandboxed host half and the real runtime: ParameterSchemaSpec
3
+ * normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
4
+ * `harness.registerTool` pair, the `harness.handle` invoke-handler normalizer, the SANDBOX CONTEXT
5
+ * FAÇADE a running plugin's `apply` receives in place of the real `ctx`, and the plugin-shape
6
+ * helpers the run lifecycle narrows sandbox return values with. The façade is a whitelist of
7
+ * lifecycle-safe verbs and declared services; framework internals and context-valued service
8
+ * returns are denied.
9
+ *
10
+ * VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
11
+ * presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
12
+ * have one meaning; invalid vocabulary fails during registration with a teaching error.
13
+ * @module @deepseek-ai/dsh-cordis-host-runner/guard
14
+ */
15
+
16
+ import { Context } from '@deepseek-ai/cordis'
17
+ import type { Plugin } from '@deepseek-ai/cordis'
18
+ import { scopeOf } from '@deepseek-ai/dsh-scope'
19
+ import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
20
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
21
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
22
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
23
+
24
+ const DYNAMIC_TOOL = Symbol('cordis-host-runner.dynamic-tool')
25
+ const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
26
+ const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
27
+ const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
28
+
29
+ type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
30
+ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
31
+
32
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
33
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
34
+ const prototype: unknown = Object.getPrototypeOf(value)
35
+ return prototype === null
36
+ || typeof prototype === 'object'
37
+ && Object.getPrototypeOf(prototype) === null
38
+ && hasIntrinsicConstructor(prototype, 'Object')
39
+ }
40
+
41
+ /* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
42
+ /** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
43
+ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
44
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
45
+ const constructor: unknown = descriptor?.value
46
+ if (typeof constructor !== 'function') return false
47
+ try {
48
+ return constructor.name === name
49
+ && constructor.prototype === prototype
50
+ && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
57
+ function hasPlainArrayPrototype(value: unknown[]): boolean {
58
+ const prototype: unknown = Object.getPrototypeOf(value)
59
+ if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
60
+ const objectPrototype: unknown = Object.getPrototypeOf(prototype)
61
+ return typeof objectPrototype === 'object'
62
+ && objectPrototype !== null
63
+ && Object.getPrototypeOf(objectPrototype) === null
64
+ && hasIntrinsicConstructor(objectPrototype, 'Object')
65
+ }
66
+ /* jscpd:ignore-end */
67
+
68
+ /** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
69
+ function isDensePlainArray(value: unknown): value is unknown[] {
70
+ if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
71
+ return false
72
+ }
73
+ for (let index = 0; index < value.length; index++) {
74
+ if (!Object.hasOwn(value, index)) return false
75
+ }
76
+ return true
77
+ }
78
+
79
+ /** Reject schema records whose declarations would disappear from object enumeration. */
80
+ function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
81
+ if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
82
+ throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
83
+ }
84
+ }
85
+
86
+ /** Where one cloned JSON value is installed. */
87
+ type CloneDestination =
88
+ | { kind: 'root' }
89
+ | { kind: 'array'; target: unknown[]; index: number }
90
+ | { kind: 'object'; target: Record<string, unknown>; key: string }
91
+
92
+ /** Deferred work for stack-safe cross-realm JSON cloning. */
93
+ type CloneTask =
94
+ | { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
95
+ | { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
96
+ | { kind: 'leave'; source: object }
97
+
98
+ /** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions; `path` carries the caller's own error prefix. */
99
+ function cloneJson(value: unknown, path: string): unknown {
100
+ const ancestors = new Set<object>()
101
+ let root: unknown
102
+ const assign = (destination: CloneDestination, item: unknown): void => {
103
+ if (destination.kind === 'root') {
104
+ root = item
105
+ return
106
+ }
107
+ if (destination.kind === 'array') {
108
+ destination.target[destination.index] = item
109
+ return
110
+ }
111
+ Object.defineProperty(destination.target, destination.key, {
112
+ value: item,
113
+ enumerable: true,
114
+ configurable: true,
115
+ writable: true,
116
+ })
117
+ }
118
+ const reject = (at: string): never => {
119
+ // Naming the executable next step matters more than naming the rule: the
120
+ // usual cause is a handler that returns whatever its last call produced,
121
+ // and the fix is one keyword.
122
+ throw new Error(`${at} must be lossless JSON data (objects, arrays, strings, numbers, booleans, null) — `
123
+ + 'not a class instance, function, Map/Set, Date, or undefined. Return a plain object built from the '
124
+ + 'values you need, or `return null` when the caller needs no value back.')
125
+ }
126
+
127
+ const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
128
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
129
+ if (task.kind === 'leave') {
130
+ ancestors.delete(task.source)
131
+ continue
132
+ }
133
+ if (task.kind === 'array-item') {
134
+ if (!Object.hasOwn(task.source, task.index)) reject(task.path)
135
+ tasks.push({
136
+ kind: 'visit',
137
+ value: task.source[task.index],
138
+ path: `${task.path}[${task.index}]`,
139
+ destination: { kind: 'array', target: task.target, index: task.index },
140
+ })
141
+ continue
142
+ }
143
+
144
+ const current = task.value
145
+ if (current === null || typeof current === 'string' || typeof current === 'boolean') {
146
+ assign(task.destination, current)
147
+ continue
148
+ }
149
+ if (typeof current === 'number') {
150
+ if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
151
+ assign(task.destination, current)
152
+ continue
153
+ }
154
+ if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
155
+
156
+ if (Array.isArray(current)) {
157
+ if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
158
+ const output: unknown[] = []
159
+ assign(task.destination, output)
160
+ ancestors.add(current)
161
+ tasks.push({ kind: 'leave', source: current })
162
+ for (let index = current.length - 1; index >= 0; index--) {
163
+ tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
164
+ }
165
+ continue
166
+ }
167
+ if (!isPlainRecord(current)) reject(task.path)
168
+ const record = current as Record<string, unknown>
169
+ if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
170
+ reject(task.path)
171
+ }
172
+ const output: Record<string, unknown> = {}
173
+ assign(task.destination, output)
174
+ ancestors.add(record)
175
+ tasks.push({ kind: 'leave', source: record })
176
+ const entries = Object.entries(record)
177
+ for (let index = entries.length - 1; index >= 0; index--) {
178
+ const entry = entries[index]
179
+ /* v8 ignore next -- the loop is bounded by the captured entry count. */
180
+ if (entry === undefined) continue
181
+ tasks.push({
182
+ kind: 'visit',
183
+ value: entry[1],
184
+ path: `${task.path}.${entry[0]}`,
185
+ destination: { kind: 'object', target: output, key: entry[0] },
186
+ })
187
+ }
188
+ }
189
+ return root
190
+ }
191
+
192
+ /** Copy and realm-materialize the shared annotation vocabulary. */
193
+ function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
194
+ if (Object.hasOwn(value, 'description')) output.description = value.description
195
+ if (Object.hasOwn(value, 'title')) output.title = value.title
196
+ if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `harness.defineTool ${path}.default`)
197
+ if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `harness.defineTool ${path}.examples`)
198
+ }
199
+
200
+ /** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
201
+ function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
202
+ assertSchemaContainerKeys(value, path)
203
+ for (const key of Object.keys(value)) {
204
+ if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Normalize a sandbox-provided `parameters` value into a fresh host-realm
210
+ * ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
211
+ * default, while the direct DSL is already an implicit open property map.
212
+ */
213
+ function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
214
+ spec: Record<string, unknown>
215
+ rootAnnotations?: Record<string, unknown>
216
+ } {
217
+ if (!isPlainRecord(value)) {
218
+ throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
219
+ }
220
+ if (value.type === 'object') {
221
+ assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
222
+ if (!isPlainRecord(value.properties)) {
223
+ throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
224
+ }
225
+ if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
226
+ throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
227
+ }
228
+ if (Object.hasOwn(value, 'required') && value.required === undefined) {
229
+ throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
230
+ }
231
+ const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
232
+ const rootAnnotations: Record<string, unknown> = {}
233
+ copyAnnotations(value, rootAnnotations, path)
234
+ return {
235
+ spec: normalizePropertyMap(value.properties, path, required, true),
236
+ ...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
237
+ }
238
+ }
239
+ return { spec: normalizePropertyMap(value, path, new Set(), false) }
240
+ }
241
+
242
+ /** Validate raw required names and return their lookup set. */
243
+ function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
244
+ if (value === undefined) return new Set()
245
+ if (!isDensePlainArray(value)) {
246
+ throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
247
+ }
248
+ const names = new Set<string>()
249
+ for (let index = 0; index < value.length; index++) {
250
+ const name = value[index]
251
+ if (typeof name !== 'string') {
252
+ throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
253
+ }
254
+ names.add(name)
255
+ if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
256
+ }
257
+ return names
258
+ }
259
+
260
+ /** Mutable holder used only while one normalized property-map root is unresolved. */
261
+ interface NormalizeRoot {
262
+ value?: Record<string, unknown>
263
+ }
264
+
265
+ /** Where a normalized value node is installed. */
266
+ type NormalizeValueDestination =
267
+ | { kind: 'property'; target: Record<string, unknown>; key: string }
268
+ | { kind: 'item'; target: Record<string, unknown> }
269
+ | { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
270
+
271
+ /** Where a normalized property map is installed. */
272
+ type NormalizeMapDestination =
273
+ | { kind: 'root'; holder: NormalizeRoot }
274
+ | { kind: 'properties'; target: Record<string, unknown> }
275
+
276
+ /** Deferred work for stack-safe sandbox schema normalization. */
277
+ type NormalizeTask =
278
+ | {
279
+ kind: 'map'
280
+ entries: Record<string, unknown>
281
+ path: string
282
+ requiredNames: ReadonlySet<string>
283
+ raw: boolean
284
+ destination: NormalizeMapDestination
285
+ }
286
+ | {
287
+ kind: 'value'
288
+ value: unknown
289
+ path: string
290
+ forceRequired: boolean
291
+ raw: boolean
292
+ parameterProperty: boolean
293
+ destination: NormalizeValueDestination
294
+ }
295
+ | { kind: 'leave'; value: object }
296
+
297
+ /** Install one normalized node without `__proto__` assignment semantics. */
298
+ function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
299
+ if (destination.kind === 'property') {
300
+ Object.defineProperty(destination.target, destination.key, {
301
+ value,
302
+ enumerable: true,
303
+ configurable: true,
304
+ writable: true,
305
+ })
306
+ } else if (destination.kind === 'item') {
307
+ destination.target.items = value
308
+ } else {
309
+ destination.target[destination.index] = value
310
+ }
311
+ }
312
+
313
+ /** Install one normalized property map at its root or containing object. */
314
+ function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
315
+ if (destination.kind === 'root') destination.holder.value = value
316
+ else destination.target.properties = value
317
+ }
318
+
319
+ /** Normalize one implicit property map and all descendants with explicit work frames. */
320
+ function normalizePropertyMap(
321
+ entries: Record<string, unknown>,
322
+ path: string,
323
+ requiredNames: ReadonlySet<string>,
324
+ raw: boolean,
325
+ ): Record<string, unknown> {
326
+ const holder: NormalizeRoot = {}
327
+ const ancestors = new Set<object>()
328
+ const tasks: NormalizeTask[] = [{
329
+ kind: 'map',
330
+ entries,
331
+ path,
332
+ requiredNames,
333
+ raw,
334
+ destination: { kind: 'root', holder },
335
+ }]
336
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
337
+ if (task.kind === 'leave') {
338
+ ancestors.delete(task.value)
339
+ continue
340
+ }
341
+ if (task.kind === 'map') {
342
+ if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
343
+ assertSchemaContainerKeys(task.entries, task.path)
344
+ ancestors.add(task.entries)
345
+ const spec: Record<string, unknown> = {}
346
+ assignNormalizedMap(task.destination, spec)
347
+ tasks.push({ kind: 'leave', value: task.entries })
348
+ const mapEntries = Object.entries(task.entries)
349
+ for (let index = mapEntries.length - 1; index >= 0; index--) {
350
+ const entry = mapEntries[index]
351
+ /* v8 ignore next -- the loop is bounded by the captured entry count. */
352
+ if (entry === undefined) continue
353
+ tasks.push({
354
+ kind: 'value',
355
+ value: entry[1],
356
+ path: `${task.path}.${entry[0]}`,
357
+ forceRequired: task.requiredNames.has(entry[0]),
358
+ raw: task.raw,
359
+ parameterProperty: true,
360
+ destination: { kind: 'property', target: spec, key: entry[0] },
361
+ })
362
+ }
363
+ continue
364
+ }
365
+
366
+ const { value, path } = task
367
+ if (!isPlainRecord(value)) {
368
+ throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
369
+ }
370
+ assertSchemaContainerKeys(value, path)
371
+ if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
372
+ ancestors.add(value)
373
+ const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
374
+ if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
375
+ throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
376
+ }
377
+ if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
378
+ throw new Error(`harness.defineTool ${path}.required must be true when present`)
379
+ }
380
+ const prop: Record<string, unknown> = {}
381
+ assignNormalizedValue(task.destination, prop)
382
+ tasks.push({ kind: 'leave', value })
383
+ if (task.forceRequired || value.required === true) prop.required = true
384
+ copyAnnotations(value, prop, path)
385
+
386
+ if (Object.hasOwn(value, 'oneOf')) {
387
+ assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
388
+ if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
389
+ throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
390
+ }
391
+ const oneOf: Record<string, unknown>[] = []
392
+ prop.oneOf = oneOf
393
+ for (let index = value.oneOf.length - 1; index >= 0; index--) {
394
+ tasks.push({
395
+ kind: 'value',
396
+ value: value.oneOf[index],
397
+ path: `${path}.oneOf[${index}]`,
398
+ forceRequired: false,
399
+ raw: task.raw,
400
+ parameterProperty: false,
401
+ destination: { kind: 'one-of', target: oneOf, index },
402
+ })
403
+ }
404
+ continue
405
+ }
406
+
407
+ if (task.raw && !Object.hasOwn(value, 'type')) {
408
+ assertSchemaKeys(value, path, ANNOTATION_KEYS)
409
+ prop.type = 'json'
410
+ continue
411
+ }
412
+ if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
413
+ throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
414
+ }
415
+ const type = value.type
416
+ prop.type = type
417
+
418
+ switch (type) {
419
+ case 'object': {
420
+ assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
421
+ if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
422
+ throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
423
+ }
424
+ if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
425
+ throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
426
+ }
427
+ if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
428
+ throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
429
+ }
430
+ prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
431
+ if (Object.hasOwn(value, 'properties')) {
432
+ const properties = value.properties
433
+ if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
434
+ const nestedRequired = task.raw
435
+ ? normalizeRequiredNames(value.required, properties, `${path}.required`)
436
+ : new Set<string>()
437
+ tasks.push({
438
+ kind: 'map',
439
+ entries: properties,
440
+ path: `${path}.properties`,
441
+ requiredNames: nestedRequired,
442
+ raw: task.raw,
443
+ destination: { kind: 'properties', target: prop },
444
+ })
445
+ } else if (task.raw && value.required !== undefined) {
446
+ normalizeRequiredNames(value.required, {}, `${path}.required`)
447
+ }
448
+ break
449
+ }
450
+ case 'array':
451
+ assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
452
+ if (Object.hasOwn(value, 'items')) {
453
+ tasks.push({
454
+ kind: 'value',
455
+ value: value.items,
456
+ path: `${path}.items`,
457
+ forceRequired: false,
458
+ raw: task.raw,
459
+ parameterProperty: false,
460
+ destination: { kind: 'item', target: prop },
461
+ })
462
+ }
463
+ break
464
+ case 'string':
465
+ case 'number':
466
+ case 'integer':
467
+ case 'boolean':
468
+ case 'null':
469
+ assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
470
+ if (Object.hasOwn(value, 'enum')) {
471
+ if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
472
+ throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
473
+ }
474
+ prop.enum = cloneJson(value.enum, `harness.defineTool ${path}.enum`)
475
+ }
476
+ if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `harness.defineTool ${path}.const`)
477
+ break
478
+ case 'json':
479
+ assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
480
+ break
481
+ /* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
482
+ default:
483
+ throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
484
+ }
485
+ }
486
+ /* v8 ignore next -- the root map task assigns before scheduling descendants. */
487
+ return holder.value ?? {}
488
+ }
489
+
490
+ function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
491
+ Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
492
+ return tool as DynamicToolDefinition
493
+ }
494
+
495
+ function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
496
+ if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
497
+ throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Structurally a content block, checked AFTER the JSON round-trip: a plain
503
+ * object carrying a string `type` tag. Deliberately nothing deeper — the
504
+ * ContentBlock union is merge-extensible (an unknown tag must pass), and every
505
+ * downstream consumer dispatches on `type` and falls through unknowns.
506
+ */
507
+ function isContentBlockShape(value: unknown): boolean {
508
+ return isPlainRecord(value) && typeof value.type === 'string'
509
+ }
510
+
511
+ /**
512
+ * How much of an invalid execute return the teaching error echoes back — a
513
+ * huge blob would burn the model turn the error is trying to save.
514
+ */
515
+ const RETURN_PREVIEW_LIMIT = 120
516
+
517
+ /**
518
+ * Compact JSON preview of an invalid execute return for the teaching error
519
+ * (`String(…)` for the un-stringifiable undefined case), truncated to
520
+ * {@link RETURN_PREVIEW_LIMIT}.
521
+ */
522
+ function describeReturn(value: JsonValue): string {
523
+ // The caller has already crossed cloneJson, so this value is lossless JSON
524
+ // and serialization cannot produce undefined.
525
+ const json = JSON.stringify(value)
526
+ return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
527
+ }
528
+
529
+ /**
530
+ * Validate and host-materialize a sandbox renderer's content blocks.
531
+ */
532
+ function assertRenderedContent(value: JsonValue): ContentBlock[] {
533
+ if (Array.isArray(value) && value.every(isContentBlockShape)) {
534
+ return value as unknown as ContentBlock[]
535
+ }
536
+ throw new Error(
537
+ `output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
538
+ + ' ✓ return [{ type: \'text\', text: String(value) }]',
539
+ )
540
+ }
541
+
542
+ /**
543
+ * The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
544
+ * into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
545
+ * required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
546
+ * via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
547
+ * the session log.
548
+ * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
549
+ * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
550
+ */
551
+ export function sandboxDefineTool(options: unknown): ToolDefinition {
552
+ if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
553
+ const normalized = normalizeParameterSchemaSpec(options.parameters)
554
+ if (!isPlainRecord(options.output)) {
555
+ throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
556
+ }
557
+ const output = options.output
558
+ if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
559
+ if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
560
+ throw new Error('harness.defineTool output.presentationMeta must be a function when present')
561
+ }
562
+ if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
563
+ const schema = cloneJson(output.schema, 'harness.defineTool output.schema')
564
+ const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
565
+ const rawRender = output.render as (args: unknown, value: unknown) => unknown
566
+ const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
567
+ const erasedDefineTool = defineTool as (definition: unknown) => ToolDefinition
568
+ const tool = erasedDefineTool({
569
+ ...options,
570
+ parameters: normalized.spec,
571
+ output: {
572
+ schema,
573
+ render(args: unknown, value: unknown): ContentBlock[] {
574
+ return assertRenderedContent(cloneJson(rawRender(args, value), 'harness.defineTool output.render result') as JsonValue)
575
+ },
576
+ ...rawPresentationMeta !== undefined ? {
577
+ presentationMeta(args: unknown, value: unknown): JsonValue {
578
+ return cloneJson(rawPresentationMeta(args, value), 'harness.defineTool output.presentationMeta result') as JsonValue
579
+ },
580
+ } : {},
581
+ },
582
+ async execute(args: unknown, exec: unknown): Promise<JsonValue> {
583
+ return cloneJson(await rawExecute(args, exec), 'harness.defineTool execute result') as JsonValue
584
+ },
585
+ })
586
+ const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
587
+ assertSupportedJsonSchema(parameters)
588
+ return markDynamicTool({
589
+ ...tool,
590
+ parameters,
591
+ })
592
+ }
593
+
594
+ /**
595
+ * Normalize one `harness.handle` registration at the sandbox boundary: the
596
+ * method name must be a non-empty string and the handler a function whose
597
+ * result is host-materialized through the same cross-realm JSON clone as tool
598
+ * `execute` returns (a VM-realm object would otherwise escape the wire's
599
+ * plain-object contract).
600
+ * @param method - handler name the package's browser half calls through `host.call`.
601
+ * @param fn - sandbox handler receiving the wire-decoded JSON arguments.
602
+ * @returns the validated name and the clone-wrapped handler.
603
+ */
604
+ export function normalizeHandler(method: unknown, fn: unknown): { method: string; handler: (args: unknown) => Promise<unknown> } {
605
+ if (typeof method !== 'string' || method.length === 0) {
606
+ throw new Error('harness.handle(method, fn) needs a non-empty string method name')
607
+ }
608
+ if (typeof fn !== 'function') {
609
+ throw new Error(`harness.handle("${method}") needs a handler function as its second argument`)
610
+ }
611
+ const rawHandler = fn as (args: unknown) => unknown
612
+ return {
613
+ method,
614
+ handler: async (args: unknown): Promise<unknown> =>
615
+ cloneJson(await rawHandler(args), `harness.handle("${method}") result`),
616
+ }
617
+ }
618
+
619
+ /**
620
+ * The `harness.registerTool` handed into the sandbox: registers a
621
+ * marker-verified dynamic tool on the given context's registry.
622
+ * @param ctx - the (guarded) context whose `tools` service receives the tool.
623
+ * @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
624
+ * @returns the registry disposer for the registration.
625
+ */
626
+ export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
627
+ assertDynamicTool(tool)
628
+ return ctx.tools.register(tool)
629
+ }
630
+
631
+ /**
632
+ * The verbs a running host half may reach through the sandbox `ctx` façade, beyond its injected
633
+ * services. `on`/`once` observe events, `provide` exposes a service to other packages, and the
634
+ * timer helpers schedule work — each a fiber effect that unwinds when the package stops.
635
+ */
636
+ const CTX_VERBS = new Set(['effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
637
+ const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
638
+
639
+ /**
640
+ * The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
641
+ * metadata (`schemas`, and `get` returning a schema view, never the live
642
+ * `ToolDefinition`). Exposing the raw definition would hand package code the
643
+ * tool's `execute` function, letting it call another tool directly and bypass
644
+ * `ToolsService.execute` — identity protection, pre-policy, monotonic guards,
645
+ * around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
646
+ * name/description/parameters view as `schemas()`, and nothing invocable.
647
+ */
648
+ function sandboxTools(ctx: Context): Record<string, unknown> {
649
+ // Resolve reads and writes through the package's own scope.
650
+ return {
651
+ register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
652
+ schemas: () => ctx.tools.schemas(scopeOf(ctx)),
653
+ get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Reject any injected-service return that is a cordis `Context`. Harness
659
+ * services return data, never a context; a value that is one would be a
660
+ * fresh, unguarded handle back into the runtime — the exact escape the façade
661
+ * exists to close — so it fails loud instead of reaching sandbox code.
662
+ */
663
+ // Twinned with the browser half's guard for the same reason as the ctx façade
664
+ // below: this is the rule "a service must never hand sandboxed code a Context",
665
+ // and each half must test against the Context class of ITS OWN face. Moving the
666
+ // rule into a shared package would move a security invariant out of the halves
667
+ // that enforce it, which is a design decision rather than a duplication fix.
668
+ /* jscpd:ignore-start */
669
+ function denyContext(value: unknown, service: string, reportFailure: (error: Error) => void): unknown {
670
+ if (value instanceof Context) {
671
+ return rejectGuard(reportFailure,
672
+ `service "${service}" returned a cordis Context, which the sandbox does not expose. `
673
+ + 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
674
+ + 'and the services you inject — never another context.',
675
+ )
676
+ }
677
+ return value
678
+ }
679
+
680
+ /**
681
+ * Wrap an injected service so its methods forward to the real instance but
682
+ * their return values pass through {@link denyContext}. Non-function members
683
+ * (plain data) pass through as-is; a returned Promise is guarded on resolve.
684
+ */
685
+ function guardedService(service: object, name: string, reportFailure: (error: Error) => void): unknown {
686
+ return new Proxy(service, {
687
+ get(target, prop) {
688
+ const value: unknown = Reflect.get(target, prop, target)
689
+ if (typeof value !== 'function') return denyContext(value, name, reportFailure)
690
+ return (...args: unknown[]): unknown => {
691
+ const result: unknown = Reflect.apply(value, target, args)
692
+ if (result instanceof Promise) return result.then(v => denyContext(v, name, reportFailure))
693
+ return denyContext(result, name, reportFailure)
694
+ }
695
+ },
696
+ })
697
+ }
698
+ /* jscpd:ignore-end */
699
+
700
+ /**
701
+ * The service names a plugin declared in `inject`, as a lookup set. Whatever
702
+ * declaration style the plugin used — an `inject: ['bash', 'tools']` array or
703
+ * the `{ required, optional }` object form — cordis resolves it into a single
704
+ * name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
705
+ * so the gate just reads that map's keys. A host half may reach only the services
706
+ * it declared — that is what lets cordis park it when a declared provider
707
+ * goes away.
708
+ */
709
+ function declaredInjects(ctx: Context): Set<string> {
710
+ return new Set(Object.keys(ctx.fiber.inject))
711
+ }
712
+
713
+ /**
714
+ * Whitelist context for running host halves: lifecycle-safe verbs, guarded
715
+ * tools, optional `ctx.get()` lookup, and declared-service property access.
716
+ * Framework plumbing is denied, and service methods cannot return a Context.
717
+ */
718
+ function sandboxContext(ctx: Context, reportFailure: (error: Error) => void): Context {
719
+ const tools = sandboxTools(ctx)
720
+ const declared = declaredInjects(ctx)
721
+ // A framework member or an undeclared service — distinguish the two so the
722
+ // error teaches the right fix (declare it in inject vs it is withheld).
723
+ const denyRead = (prop: string): never => {
724
+ if (ctx.get(prop) !== undefined) {
725
+ return rejectGuard(reportFailure,
726
+ `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
727
+ + 'so cordis parks this dynamic package if the provider later goes away.',
728
+ )
729
+ }
730
+ return rejectGuard(reportFailure,
731
+ `sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
732
+ + 'the timer helpers after injecting timer, and any service you declared in inject. '
733
+ + 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
734
+ )
735
+ }
736
+ // `get` is optional lookup; property access requires a declaration. `tools`
737
+ // is the façade's own API on either path.
738
+ const readService = (name: string, requireDeclaration: boolean): unknown => {
739
+ if (name === 'tools') return tools
740
+ if (requireDeclaration && !declared.has(name)) return denyRead(name)
741
+ const service = denyContext(ctx.get(name), name, reportFailure)
742
+ if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
743
+ return guardedService(service, name, reportFailure)
744
+ }
745
+ const get = (name: string): unknown => readService(name, false)
746
+ // The browser half builds the same façade over its own Context
747
+ // (`@deepseek-ai/dsh-cordis-client-runner`, whose CTX_VERBS names this one its
748
+ // twin), and the sameness is the point: a package author meets ONE contract on
749
+ // both halves. Folding them together is not available — the two halves compile
750
+ // in separate programs where `Context` merges different service keys — so the
751
+ // duplication is declared here instead of hidden behind a config exception.
752
+ /* jscpd:ignore-start */
753
+ return new Proxy({}, {
754
+ get(_target, prop) {
755
+ if (prop === 'tools') return tools
756
+ if (prop === 'get') return get
757
+ if (typeof prop !== 'string') return undefined
758
+ // Lazy verb forwarder — reads `ctx[verb]` only when called. Timer mixins
759
+ // additionally require the Service declaration before Cordis resolves them.
760
+ if (CTX_VERBS.has(prop)) {
761
+ return (...args: unknown[]): unknown => {
762
+ if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
763
+ const method = ctx[prop as keyof Context]
764
+ return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
765
+ }
766
+ }
767
+ return readService(prop, true)
768
+ },
769
+ // A façade is not the real ctx; block writes rather than let package code
770
+ // stash state on a throwaway object and think it persisted.
771
+ set(_target, prop) {
772
+ return rejectGuard(reportFailure, `sandbox ctx is read-only; cannot assign "${String(prop)}"`)
773
+ },
774
+ // `in` reflects reachability: the façade API plus DECLARED services
775
+ // (whether or not currently live). Does not resolve/wrap — no throw.
776
+ has: (_target, prop) => prop === 'tools' || prop === 'get'
777
+ || (typeof prop === 'string'
778
+ && ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
779
+ }) as Context
780
+ /* jscpd:ignore-end */
781
+ }
782
+
783
+ /**
784
+ * Narrow an arbitrary sandbox return value to a runnable cordis plugin: a
785
+ * function, or an object with an `apply` function. (A bare function passes the
786
+ * first arm, so the object arm never sees `Function.prototype.apply`.)
787
+ * @param value - whatever the host half returned.
788
+ * @returns whether the value can be started via `ctx.plugin`.
789
+ */
790
+ export function isPlugin(value: unknown): value is Plugin {
791
+ if (typeof value === 'function') return true
792
+ return typeof value === 'object' && value !== null
793
+ && typeof (value as { apply?: unknown }).apply === 'function'
794
+ }
795
+
796
+ /**
797
+ * Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
798
+ * @param plugin - the plugin the host half returned.
799
+ * @param reportFailure - reports a guard rejection to the owning Agent.
800
+ * @returns an equivalent plugin whose `apply` sees the sandbox context façade.
801
+ */
802
+ export function guardedPlugin(plugin: Plugin, reportFailure: (error: Error) => void): Plugin {
803
+ if (typeof plugin === 'function') {
804
+ const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
805
+ return {
806
+ name: pluginName(plugin),
807
+ apply(ctx: Context, config?: unknown) {
808
+ return functionPlugin(sandboxContext(ctx, reportFailure), config)
809
+ },
810
+ }
811
+ }
812
+ const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
813
+ return {
814
+ ...plugin,
815
+ apply(ctx: Context, config?: unknown) {
816
+ return objectPlugin.apply(sandboxContext(ctx, reportFailure), config)
817
+ },
818
+ }
819
+ }
820
+
821
+ function rejectGuard(reportFailure: (error: Error) => void, message: string): never {
822
+ const error = new Error(message)
823
+ reportFailure(error)
824
+ throw error
825
+ }
826
+
827
+ /**
828
+ * Display name for a running plugin: its `name` property, else anonymous.
829
+ * @param plugin - the plugin the host half returned.
830
+ * @returns the human-readable name used in run results and inspect output.
831
+ */
832
+ export function pluginName(plugin: Plugin): string {
833
+ const named = (plugin as { name?: unknown }).name
834
+ if (typeof named === 'string' && named.length > 0) return named
835
+ return '<anonymous>'
836
+ }