Skip to content

Commit 5ec0844

Browse files
committed
chore: add function to sanitize params for tool calls
1 parent 0a7c0a7 commit 5ec0844

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

src/telemetry/ClearcutLogger.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import process from 'node:process';
88

99
import {DAEMON_CLIENT_NAME} from '../daemon/utils.js';
1010
import {logger} from '../logger.js';
11+
import type {zod} from '../third_party/index.js';
12+
import type {ShapeOutput} from '../third_party/index.js';
1113

1214
import type {LocalState, Persistence} from './persistence.js';
1315
import {FilePersistence} from './persistence.js';
@@ -20,6 +22,108 @@ import {
2022
import {WatchdogClient} from './WatchdogClient.js';
2123

2224
const MS_PER_DAY = 24 * 60 * 60 * 1000;
25+
const PARAM_BLOCKLIST = new Set(['uid']);
26+
27+
const SUPPORTED_ZOD_TYPES = [
28+
'ZodString',
29+
'ZodNumber',
30+
'ZodBoolean',
31+
'ZodArray',
32+
'ZodEnum',
33+
] as const;
34+
type ZodType = (typeof SUPPORTED_ZOD_TYPES)[number];
35+
36+
function isZodType(type: string): type is ZodType {
37+
return SUPPORTED_ZOD_TYPES.includes(type as ZodType);
38+
}
39+
40+
function getZodType(zodType: zod.ZodTypeAny): ZodType {
41+
const def = zodType._def;
42+
const typeName = def.typeName;
43+
44+
if (
45+
typeName === 'ZodOptional' ||
46+
typeName === 'ZodDefault' ||
47+
typeName === 'ZodNullable'
48+
) {
49+
return getZodType(def.innerType);
50+
}
51+
if (typeName === 'ZodEffects') {
52+
return getZodType(def.schema);
53+
}
54+
55+
if (isZodType(typeName)) {
56+
return typeName;
57+
}
58+
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
59+
}
60+
61+
type LoggedToolCallArgValue = string | number | boolean;
62+
63+
function transformName(zodType: ZodType, name: string): string {
64+
if (zodType === 'ZodString') {
65+
return `${name}_length`;
66+
} else if (zodType === 'ZodArray') {
67+
return `${name}_count`;
68+
} else {
69+
return name;
70+
}
71+
}
72+
73+
function transformValue(
74+
zodType: ZodType,
75+
value: unknown,
76+
): LoggedToolCallArgValue {
77+
if (zodType === 'ZodString') {
78+
return (value as string).length;
79+
} else if (zodType === 'ZodArray') {
80+
return (value as unknown[]).length;
81+
} else {
82+
return value as LoggedToolCallArgValue;
83+
}
84+
}
85+
86+
function hasEquivalentType(zodType: ZodType, value: unknown): boolean {
87+
if (zodType === 'ZodString') {
88+
return typeof value === 'string';
89+
} else if (zodType === 'ZodArray') {
90+
return Array.isArray(value);
91+
} else if (zodType === 'ZodNumber') {
92+
return typeof value === 'number';
93+
} else if (zodType === 'ZodBoolean') {
94+
return typeof value === 'boolean';
95+
} else if (zodType === 'ZodEnum') {
96+
return (
97+
typeof value === 'string' ||
98+
typeof value === 'number' ||
99+
typeof value === 'boolean'
100+
);
101+
} else {
102+
return false;
103+
}
104+
}
105+
106+
export function sanitizeParams(
107+
params: ShapeOutput<zod.ZodRawShape>,
108+
schema: zod.ZodRawShape,
109+
): ShapeOutput<zod.ZodRawShape> {
110+
const transformed: ShapeOutput<zod.ZodRawShape> = {};
111+
for (const [name, value] of Object.entries(params)) {
112+
if (PARAM_BLOCKLIST.has(name)) {
113+
continue;
114+
}
115+
const zodType = getZodType(schema[name]);
116+
if (!hasEquivalentType(zodType, value)) {
117+
throw new Error(
118+
`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`,
119+
);
120+
}
121+
const transformedName = transformName(zodType, name);
122+
const transformedValue = transformValue(zodType, value);
123+
transformed[transformedName] = transformedValue;
124+
}
125+
return transformed;
126+
}
23127

24128
function detectOsType(): OsType {
25129
switch (process.platform) {

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {hideBin} from 'yargs/helpers';
1919
export {default as debug} from 'debug';
2020
export type {Debugger} from 'debug';
2121
export {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
22+
export {type ShapeOutput} from '@modelcontextprotocol/sdk/server/zod-compat.js';
2223
export {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
2324
export {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
2425
export {Client} from '@modelcontextprotocol/sdk/client/index.js';

tests/telemetry/ClearcutLogger.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ import {describe, it, afterEach, beforeEach} from 'node:test';
1010
import sinon from 'sinon';
1111

1212
import {DAEMON_CLIENT_NAME} from '../../src/daemon/utils.js';
13-
import {ClearcutLogger} from '../../src/telemetry/ClearcutLogger.js';
13+
import {
14+
ClearcutLogger,
15+
sanitizeParams,
16+
} from '../../src/telemetry/ClearcutLogger.js';
1417
import type {Persistence} from '../../src/telemetry/persistence.js';
1518
import {FilePersistence} from '../../src/telemetry/persistence.js';
1619
import {WatchdogMessageType} from '../../src/telemetry/types.js';
1720
import {WatchdogClient} from '../../src/telemetry/WatchdogClient.js';
21+
import {zod} from '../../src/third_party/index.js';
1822

1923
describe('ClearcutLogger', () => {
2024
let mockPersistence: sinon.SinonStubbedInstance<Persistence>;
@@ -163,4 +167,50 @@ describe('ClearcutLogger', () => {
163167
assert(mockPersistence.saveState.called);
164168
});
165169
});
170+
171+
describe('sanitizeParams', () => {
172+
it('filters out uid and transforms strings and arrays', () => {
173+
const schema = {
174+
uid: zod.string(),
175+
myString: zod.string(),
176+
myArray: zod.array(zod.string()),
177+
myNumber: zod.number(),
178+
myBool: zod.boolean(),
179+
myEnum: zod.enum(['a', 'b']),
180+
};
181+
182+
const params = {
183+
uid: 'sensitive',
184+
myString: 'hello',
185+
myArray: ['one', 'two'],
186+
myNumber: 42,
187+
myBool: true,
188+
myEnum: 'a' as const,
189+
};
190+
191+
const sanitized = sanitizeParams(params, schema);
192+
193+
assert.deepStrictEqual(sanitized, {
194+
myString_length: 5,
195+
myArray_count: 2,
196+
myNumber: 42,
197+
myBool: true,
198+
myEnum: 'a',
199+
});
200+
});
201+
202+
it('throws error for unsupported types', () => {
203+
const schema = {
204+
myObj: zod.object({foo: zod.string()}),
205+
};
206+
const params = {
207+
myObj: {foo: 'bar'},
208+
};
209+
210+
assert.throws(
211+
() => sanitizeParams(params, schema),
212+
/Unsupported zod type for tool parameter: ZodObject/,
213+
);
214+
});
215+
});
166216
});

0 commit comments

Comments
 (0)