Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,8 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"ajv": "^8.20.0"
}
}
100 changes: 64 additions & 36 deletions packages/agent-cli/src/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ interface ToolCatalogEntry {
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
additionalProperties: boolean;
};
buildCommand: (context: {
Expand All @@ -568,6 +569,11 @@ interface ToolCatalogEntry {
}

interface InProcessToolEntry {
/**
* Same declaration the catalog exposes, so the in-process executor validates
* tool input exactly like the spawned-CLI executor does.
*/
inputSchema: ToolCatalogEntry['inputSchema'];
execute: (context: {
dataFile: string;
input: Record<string, unknown>;
Expand Down Expand Up @@ -611,40 +617,6 @@ function appendToolSpecificOptions(
return nextCommand;
}

const toolInputSchema = {
type: 'object' as const,
properties: {
filter: {
type: ['string', 'array'],
description:
'Optional field filter. Supports comma-separated paths like "items.id,items.name".',
},
page: {
type: 'integer',
minimum: 1,
description: 'Optional page number for response pagination.',
},
pageNumber: {
type: 'integer',
minimum: 1,
description: 'Alias for page.',
},
pageSize: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: 'Optional page size for response pagination.',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: 'Alias for pageSize and a bound for aggregate tool details.',
},
} as Record<string, unknown>,
additionalProperties: true,
};

export function getToolCatalog(): ToolCatalogEntry[] {
const tools: ToolCatalogEntry[] = [];
for (const [group, subcommands] of Object.entries(SUBCOMMANDS)) {
Expand All @@ -653,7 +625,7 @@ export function getToolCatalog(): ToolCatalogEntry[] {
tools.push({
name: def.toolName,
description: def.toolDescription ?? def.description,
inputSchema: toolInputSchema,
inputSchema: buildToolInputSchema(def.options),
sourcePagination: getSourcePaginationConfig(def.options),
buildCommand: ({ dataFile, input }) =>
appendToolSpecificOptions(
Expand Down Expand Up @@ -696,7 +668,7 @@ export function describeRunSubcommands(): Array<{
toolName: def.toolName,
path: `${group}.${subcommand}`,
description: def.toolDescription ?? def.description,
args: toolInputSchema as Record<string, unknown>,
args: buildToolInputSchema(def.options) as Record<string, unknown>,
});
}
}
Expand All @@ -713,6 +685,7 @@ export function getInProcessToolExecutors(): Record<
for (const def of Object.values(subcommands)) {
if (!def.toolName) continue;
tools[def.toolName] = {
inputSchema: buildToolInputSchema(def.options),
sourcePagination: getSourcePaginationConfig(def.options),
execute: async ({ dataFile, input }) => {
setDataFilePath(dataFile);
Expand Down Expand Up @@ -757,6 +730,61 @@ function buildInputSchema(options: OptionDef[]): Record<string, unknown> {
return schema;
}

function buildToolInputSchema(
options: OptionDef[],
): ToolCatalogEntry['inputSchema'] {
const properties: Record<string, unknown> = {
filter: {
type: ['string', 'array'],
description:
'Optional field filter. Supports comma-separated paths like "items.id,items.name".',
},
page: {
type: 'integer',
minimum: 1,
description: 'Optional page number for response pagination.',
},
pageNumber: {
type: 'integer',
minimum: 1,
description: 'Alias for page.',
},
pageSize: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: 'Optional page size for response pagination.',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: 'Alias for pageSize and a bound for aggregate tool details.',
},
};
const required: string[] = [];

for (const option of options) {
const name = option.name.replace(/^--/, '');
if (name === 'page-number' || name === 'page-size') {
continue;
}
if (properties[name] === undefined) {
properties[name] = optionToJsonSchema(option);
}
if (option.required) {
required.push(name);
}
}

return {
type: 'object',
properties,
...(required.length > 0 ? { required } : {}),
additionalProperties: false,
};
}

export function describeCommandSchema(commandPath: string): unknown {
const [group, subcommand] = commandPath.split('.');
if (!group || !subcommand) {
Expand Down
17 changes: 15 additions & 2 deletions packages/agent-cli/src/core/result-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,20 @@ interface ParsedControls {
paginateResult: boolean;
}

const CONTROL_KEYS = new Set(['filter', 'page', 'pageNumber', 'pageSize']);
export const TOOL_INPUT_CONTROL_KEYS: ReadonlySet<string> = new Set([
'filter',
'limit',
'page',
'pageNumber',
'pageSize',
]);

const STRIPPED_TOOL_INPUT_CONTROL_KEYS = new Set([
'filter',
'page',
'pageNumber',
'pageSize',
]);

function parsePositiveInteger(
value: unknown,
Expand Down Expand Up @@ -236,7 +249,7 @@ export function splitToolInputControls(

const passthroughInput: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (CONTROL_KEYS.has(key)) {
if (STRIPPED_TOOL_INPUT_CONTROL_KEYS.has(key)) {
continue;
}
if (
Expand Down
195 changes: 195 additions & 0 deletions packages/agent-cli/src/core/validate-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv';

import type { JsonSchema } from './types';

export interface ToolInputValidationIssue {
path: string;
message: string;
}

export class ToolInputValidationError extends Error {
readonly toolName: string;
readonly issues: ToolInputValidationIssue[];

constructor(toolName: string, issues: ToolInputValidationIssue[]) {
super(
`Invalid input for rsdoctor tool ${toolName}: ${issues
.map((issue) => issue.message)
.join('; ')}`,
);
this.name = 'ToolInputValidationError';
this.toolName = toolName;
this.issues = issues;
}
}

export interface ToolInputValidationOptions {
allowedAdditionalProperties?: ReadonlySet<string>;
}

const ajv = new Ajv({
allErrors: true,
allowUnionTypes: true,
strict: false,
});

const validatorCache = new WeakMap<
JsonSchema,
Map<ReadonlySet<string> | undefined, ValidateFunction>
>();

function buildValidationSchema(
schema: JsonSchema,
allowedAdditionalProperties?: ReadonlySet<string>,
): JsonSchema {
if (
schema.additionalProperties !== false ||
allowedAdditionalProperties === undefined
) {
return schema;
}

const properties = { ...schema.properties };
for (const property of allowedAdditionalProperties) {
properties[property] ??= {};
}

return { ...schema, properties };
}

function getValidator(
schema: JsonSchema,
allowedAdditionalProperties?: ReadonlySet<string>,
): ValidateFunction {
let validators = validatorCache.get(schema);
if (validators === undefined) {
validators = new Map();
validatorCache.set(schema, validators);
}

let validate = validators.get(allowedAdditionalProperties);
if (validate === undefined) {
validate = ajv.compile(
buildValidationSchema(schema, allowedAdditionalProperties),
);
validators.set(allowedAdditionalProperties, validate);
}
return validate;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function normalizeNumericStrings(value: unknown, schema: unknown): unknown {
if (!isRecord(schema)) return value;

const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const acceptsString = types.includes('string');
if (typeof value === 'string' && !acceptsString && value.trim() !== '') {
const parsed = Number(value);
if (
Number.isFinite(parsed) &&
(types.includes('number') ||
(types.includes('integer') && Number.isInteger(parsed)))
) {
return parsed;
}
}

if (Array.isArray(value)) {
return value.map((item) => normalizeNumericStrings(item, schema.items));
}

if (!isRecord(value)) return value;

const properties = isRecord(schema.properties) ? schema.properties : {};
const additionalProperties = isRecord(schema.additionalProperties)
? schema.additionalProperties
: undefined;

return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
normalizeNumericStrings(item, properties[key] ?? additionalProperties),
]),
);
}

function decodePointerSegment(segment: string): string {
return segment.replaceAll('~1', '/').replaceAll('~0', '~');
}

function getPathSegments(instancePath: string): string[] {
return instancePath
.split('/')
.slice(1)
.map((segment) => decodePointerSegment(segment));
}

function formatPath(segments: string[]): string {
return segments.reduce(
(path, segment) =>
/^\d+$/.test(segment)
? `${path}[${segment}]`
: path === ''
? segment
: `${path}.${segment}`,
'',
);
}

function describeLabel(path: string): string {
return path === '' ? 'input' : `"${path}"`;
}

function formatIssue(error: ErrorObject): ToolInputValidationIssue {
const segments = getPathSegments(error.instancePath);
const basePath = formatPath(segments);

if (error.keyword === 'required') {
return {
path: formatPath([...segments, String(error.params.missingProperty)]),
message: `${describeLabel(basePath)} ${error.message ?? 'is invalid'}`,
};
}

if (error.keyword === 'additionalProperties') {
const path = formatPath([
...segments,
String(error.params.additionalProperty),
]);
return {
path,
message: `${describeLabel(path)} is not an allowed property`,
};
}

return {
path: basePath,
message: `${describeLabel(basePath)} ${error.message ?? 'is invalid'}`,
};
}

export function validateToolInput(
toolName: string,
input: unknown,
schema: JsonSchema | undefined,
options: ToolInputValidationOptions = {},
): void {
if (schema === undefined) return;

const validate = getValidator(schema, options.allowedAdditionalProperties);
if (validate(normalizeNumericStrings(input, schema))) return;

throw new ToolInputValidationError(
toolName,
(validate.errors ?? [])
.toSorted(
(left, right) =>
Number(left.keyword === 'additionalProperties') -
Number(right.keyword === 'additionalProperties'),
)
.map(formatIssue),
);
}
Loading