@deepseek-ai/dsh-cordis-host-runner 0.0.1-rc.3

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