fix(cordis): reject lossy dynamic schemas

This commit is contained in:
Tianyi Cui
2026-07-23 02:12:47 +08:00
parent e20dea1629
commit 71a5c3fd4c
6 changed files with 49 additions and 14 deletions

View File

@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
## 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

View File

@@ -62,6 +62,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<string, unknown>, 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' }
@@ -173,6 +191,7 @@ function copyAnnotations(value: Record<string, unknown>, output: Record<string,
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
function assertSchemaKeys(value: Record<string, unknown>, 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`)
}
@@ -215,11 +234,16 @@ function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
/** Validate raw required names and return their lookup set. */
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
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<string>()
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
@@ -308,6 +332,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<string, unknown> = {}
assignNormalizedMap(task.destination, spec)
@@ -334,6 +359,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'] : []
@@ -351,7 +377,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<string, unknown>[] = []
prop.oneOf = oneOf
for (let index = value.oneOf.length - 1; index >= 0; index--) {
@@ -432,9 +460,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

View File

@@ -370,7 +370,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'],
@@ -381,6 +384,7 @@ 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: (() => { 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'],
@@ -390,7 +394,9 @@ 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: { 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'],