mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(tools): validate model-generated tool args at the boundary (RFC 005 pt 1)
defineTool now runs validateArgs against the SchemaSpec before execute, so a malformed model call returns a self-correctable isError result listing the violations instead of reaching the typed body untyped-in-practice. The validator mirrors schemaSpecToJsonSchema semantics exactly (required from required:true only, extra keys allowed, default not applied, object/array without properties/items only type-checks, enum membership). tool-bash's hand-rolled type/required checks (carrying the TODO(RFC 005) stopgap note) are slimmed to just the value constraints the DSL can't express (non-empty strings, positive timeout). Graduates RFC 005 pt 1 to ADR 0011.
This commit is contained in:
@@ -28,14 +28,11 @@ export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash']
|
||||
|
||||
/**
|
||||
* Validate model-produced arguments. `defineTool`'s `InferArgs` typing is
|
||||
* compile-time only — at runtime `arguments` is whatever JSON the model
|
||||
* emitted, so every field is checked before it reaches the executor.
|
||||
*
|
||||
* TODO(RFC 005): this hand-rolled validation is the per-tool stopgap until
|
||||
* `defineTool` validates parsed args against the SchemaSpec itself (the
|
||||
* converter already encodes the structure). When that lands, delete this and
|
||||
* let the registry reject malformed calls — see docs/rfc/005.
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (RFC 005
|
||||
* → ADR 0011), so type/required/enum checks are already done and `args` is
|
||||
* the validated `InferArgs` shape here. What remains are value constraints the
|
||||
* DSL has no vocabulary for: non-empty strings and a positive, finite timeout.
|
||||
*/
|
||||
function validateBashArgs(args: {
|
||||
command: string
|
||||
@@ -44,27 +41,24 @@ function validateBashArgs(args: {
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
}): void {
|
||||
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
if (typeof args.description !== 'string' || args.description.trim().length === 0) {
|
||||
if (args.description.trim().length === 0) {
|
||||
throw new Error('invalid description: expected a non-empty string')
|
||||
}
|
||||
if (args.timeoutMs !== undefined
|
||||
&& (typeof args.timeoutMs !== 'number' || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
if (args.workdir !== undefined && typeof args.workdir !== 'string') {
|
||||
throw new Error(`invalid workdir: expected a string, got ${JSON.stringify(args.workdir)}`)
|
||||
}
|
||||
if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') {
|
||||
throw new Error(`invalid run_in_background: expected a boolean, got ${JSON.stringify(args.run_in_background)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Require a string `task_id` (model-produced, so runtime-checked). */
|
||||
function validateTaskId(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (ADR 0011); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): string {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -118,19 +118,30 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
})
|
||||
|
||||
// Type and required-key violations are now rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — ADR 0011) before execute.
|
||||
it.each([
|
||||
[{}, /invalid command/],
|
||||
[{ command: 42 }, /invalid command/],
|
||||
[{ command: ' ' }, /invalid command/],
|
||||
[{ command: 'x' }, /invalid description/],
|
||||
[{ command: 'x', description: '' }, /invalid description/],
|
||||
[{ command: 'x', description: 7 }, /invalid description/],
|
||||
[{ command: 'x', description: 'd', timeoutMs: 'soon' }, /invalid timeoutMs/],
|
||||
[{}, /missing required property "command"/],
|
||||
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
||||
[{ command: 'x' }, /missing required property "description"/],
|
||||
[{ command: 'x', description: 7 }, /"description" must be a string/],
|
||||
[{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
|
||||
[{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
|
||||
[{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
|
||||
])('rejects schema-invalid args %j', async (args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
// Value constraints the SchemaSpec can't express stay in the tool body.
|
||||
it.each([
|
||||
[{ command: ' ', description: 'd' }, /invalid command/],
|
||||
[{ command: 'x', description: ' ' }, /invalid description/],
|
||||
[{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
|
||||
[{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
|
||||
[{ command: 'x', description: 'd', workdir: 7 }, /invalid workdir/],
|
||||
[{ command: 'x', description: 'd', run_in_background: 'yes' }, /invalid run_in_background/],
|
||||
])('rejects invalid args %j', async (args, pattern) => {
|
||||
])('rejects value-invalid args %j', async (args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', args)
|
||||
expect(result.isError).toBe(true)
|
||||
@@ -241,14 +252,14 @@ describe('background tools', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bash_output', {}],
|
||||
['bash_output', { task_id: 9 }],
|
||||
['bash_kill', { task_id: '' }],
|
||||
])('%s rejects invalid task_id %j', async (tool, args) => {
|
||||
['bash_output', {}, /missing required property "task_id"/],
|
||||
['bash_output', { task_id: 9 }, /"task_id" must be a string/],
|
||||
['bash_kill', { task_id: '' }, /invalid task_id/],
|
||||
])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, tool, args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/invalid task_id/)
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
it('injects a completion notice into the owning agent', async () => {
|
||||
|
||||
Reference in New Issue
Block a user