Skip to content

Commit d5c783c

Browse files
author
SqlRush
committed
Expand tool schema validation
1 parent c296f52 commit d5c783c

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ M5 补充:通用 tool schema validator 现在支持数字 `minimum`/`maximum`
293293

294294
M5/M9 补充:通用 tool schema validator 现在支持 `required` 的 Go `[]string` 形态和 object `additionalProperties` schema 校验,MCP `get_prompt.arguments` 会在 schema 层拒绝非字符串参数值。
295295

296+
M5/M9 补充:通用 tool schema validator 现在支持 `const``pattern``maxLength``minItems`/`maxItems``minProperties`/`maxProperties``exclusiveMinimum`/`exclusiveMaximum` 以及 `allOf`/`anyOf`/`oneOf`,并兼容 Go 代码直接构造的 typed schema list,外部 MCP 工具 schema 的基础 JSON Schema 约束会在本地调用前执行。
297+
296298
M7 补充:scripted permission payload、dialog expectation、event、cancel-permission 和 dialog-result expectation 现在接受 `ID`/`ToolName`/`Actions``permissionID``requestID``toolUseID``operationID``operation``commandName``resourcePath``body``reasonText``allowedActions``buttons` 等相邻字段,并支持数字 request ID。
297299

298300
M6 补充:microcompact disk cache loader 和 prune 现在接受 digest 缺失但文件名已 keyed 的 cache entry,会用 `<digest>.json` 文件名作为 digest fallback,同时保留显式 digest mismatch 的 invalid-cache guard。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ test/parity/ # golden tests against TS/official behavior
187187
- 本轮补充:通用 tool schema validator 现在支持 `enum`,可直接执行 Grep output mode、NotebookEdit edit mode/cell type、Todo status/priority、Task target/action、LSP severity 等工具 schema 的枚举契约。
188188
- 本轮补充:通用 tool schema validator 现在支持数字 `minimum`/`maximum`,可直接执行 LSPDiagnostics `limit` 等工具 schema 的数值范围契约。
189189
- 本轮补充:通用 tool schema validator 现在支持 `required` 的 Go `[]string` 形态和 object `additionalProperties` schema 校验,MCP `get_prompt.arguments` 会在 schema 层拒绝非字符串参数值。
190+
- 本轮补充:通用 tool schema validator 现在支持 `const``pattern``maxLength``minItems`/`maxItems``minProperties`/`maxProperties``exclusiveMinimum`/`exclusiveMaximum` 以及 `allOf`/`anyOf`/`oneOf`,并兼容 Go 代码直接构造的 typed schema list,外部 MCP 工具 schema 的基础 JSON Schema 约束会在本地调用前执行。
190191
- 本轮补充:WebFetch/WebSearch 输入解码现在兼容 `timeout``max_bytes`/`maxBytes``max_results`/`maxResults` 的 quoted semantic string 数值;WebSearch 也会按官方校验拒绝同一请求同时设置 `allowed_domains``blocked_domains`
191192
- 本轮补充:Grep 现在支持 whole-word 搜索参数 `word_regexp`/`wordRegexp`/`word-regexp`/`-w`,在 regex 和 fixed-string 模式下按词边界过滤匹配,并兼容 quoted boolean 输入。
192193
- 本轮补充:Grep 现在支持反向匹配参数 `invert_match`/`invertMatch`/`invert-match`/`-v``files_with_matches``content``count` 和 multiline 模式都会按非匹配行/未覆盖行输出,并兼容 quoted boolean 输入。

internal/tool/schema.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"math"
7+
"regexp"
78
"strings"
89
"unicode/utf8"
910

@@ -23,11 +24,34 @@ func ValidateSchema(schema contracts.JSONSchema, raw json.RawMessage) error {
2324
}
2425

2526
func validateValue(schema contracts.JSONSchema, value any, path string) error {
27+
if allOf := schemaList(schema["allOf"]); len(allOf) > 0 {
28+
for _, childSchema := range allOf {
29+
if err := validateValue(childSchema, value, path); err != nil {
30+
return err
31+
}
32+
}
33+
}
34+
if anyOf := schemaList(schema["anyOf"]); len(anyOf) > 0 {
35+
if !matchesAtLeastOneSchema(anyOf, value, path) {
36+
return fmt.Errorf("%s must match at least one allowed schema", path)
37+
}
38+
}
39+
if oneOf := schemaList(schema["oneOf"]); len(oneOf) > 0 {
40+
matches := countMatchingSchemas(oneOf, value, path)
41+
if matches != 1 {
42+
return fmt.Errorf("%s must match exactly one allowed schema", path)
43+
}
44+
}
2645
if types := stringOrStrings(schema["type"]); len(types) > 0 {
2746
if !matchesAnyType(types, value) {
2847
return fmt.Errorf("%s must be %s", path, strings.Join(types, " or "))
2948
}
3049
}
50+
if constValue, ok := schema["const"]; ok {
51+
if !equalSchemaValue(value, constValue) {
52+
return fmt.Errorf("%s must be %s", path, fmt.Sprint(constValue))
53+
}
54+
}
3155
if enumValues, ok := schemaEnumValues(schema["enum"]); ok {
3256
if !matchesEnumValue(value, enumValues) {
3357
return fmt.Errorf("%s must be one of %s", path, describeEnumValues(enumValues))
@@ -38,17 +62,57 @@ func validateValue(schema contracts.JSONSchema, value any, path string) error {
3862
return fmt.Errorf("%s must be at least %s", path, describeSchemaNumber(minimum))
3963
}
4064
}
65+
if exclusiveMinimum, ok := exclusiveNumberConstraint(schema["exclusiveMinimum"], schema["minimum"]); ok {
66+
if number, ok := schemaNumber(value); ok && number <= exclusiveMinimum {
67+
return fmt.Errorf("%s must be greater than %s", path, describeSchemaNumber(exclusiveMinimum))
68+
}
69+
}
4170
if maximum, ok := schemaNumberConstraint(schema["maximum"]); ok {
4271
if number, ok := schemaNumber(value); ok && number > maximum {
4372
return fmt.Errorf("%s must be at most %s", path, describeSchemaNumber(maximum))
4473
}
4574
}
75+
if exclusiveMaximum, ok := exclusiveNumberConstraint(schema["exclusiveMaximum"], schema["maximum"]); ok {
76+
if number, ok := schemaNumber(value); ok && number >= exclusiveMaximum {
77+
return fmt.Errorf("%s must be less than %s", path, describeSchemaNumber(exclusiveMaximum))
78+
}
79+
}
4680
if minLength, ok := intSchemaConstraint(schema["minLength"]); ok {
4781
text, ok := value.(string)
4882
if ok && utf8.RuneCountInString(text) < minLength {
4983
return fmt.Errorf("%s must be at least %d characters", path, minLength)
5084
}
5185
}
86+
if maxLength, ok := intSchemaConstraint(schema["maxLength"]); ok {
87+
text, ok := value.(string)
88+
if ok && utf8.RuneCountInString(text) > maxLength {
89+
return fmt.Errorf("%s must be at most %d characters", path, maxLength)
90+
}
91+
}
92+
if pattern, ok := schema["pattern"].(string); ok && pattern != "" {
93+
text, ok := value.(string)
94+
if ok {
95+
matched, err := regexp.MatchString(pattern, text)
96+
if err != nil {
97+
return fmt.Errorf("%s has invalid pattern %q: %w", path, pattern, err)
98+
}
99+
if !matched {
100+
return fmt.Errorf("%s must match pattern %s", path, pattern)
101+
}
102+
}
103+
}
104+
if minItems, ok := intSchemaConstraint(schema["minItems"]); ok {
105+
items, ok := value.([]any)
106+
if ok && len(items) < minItems {
107+
return fmt.Errorf("%s must contain at least %d items", path, minItems)
108+
}
109+
}
110+
if maxItems, ok := intSchemaConstraint(schema["maxItems"]); ok {
111+
items, ok := value.([]any)
112+
if ok && len(items) > maxItems {
113+
return fmt.Errorf("%s must contain at most %d items", path, maxItems)
114+
}
115+
}
52116
if itemsSchema, ok := schema["items"].(map[string]any); ok {
53117
items, ok := value.([]any)
54118
if ok {
@@ -77,6 +141,12 @@ func validateValue(schema contracts.JSONSchema, value any, path string) error {
77141
if !ok {
78142
return nil
79143
}
144+
if minProperties, ok := intSchemaConstraint(schema["minProperties"]); ok && len(obj) < minProperties {
145+
return fmt.Errorf("%s must contain at least %d properties", path, minProperties)
146+
}
147+
if maxProperties, ok := intSchemaConstraint(schema["maxProperties"]); ok && len(obj) > maxProperties {
148+
return fmt.Errorf("%s must contain at most %d properties", path, maxProperties)
149+
}
80150
if ok {
81151
for key, propertySchema := range properties {
82152
child, ok := obj[key]
@@ -111,6 +181,48 @@ func validateValue(schema contracts.JSONSchema, value any, path string) error {
111181
return nil
112182
}
113183

184+
func schemaList(value any) []contracts.JSONSchema {
185+
switch items := value.(type) {
186+
case []any:
187+
out := make([]contracts.JSONSchema, 0, len(items))
188+
for _, item := range items {
189+
if child, ok := item.(map[string]any); ok {
190+
out = append(out, contracts.JSONSchema(child))
191+
}
192+
}
193+
return out
194+
case []map[string]any:
195+
out := make([]contracts.JSONSchema, 0, len(items))
196+
for _, item := range items {
197+
out = append(out, contracts.JSONSchema(item))
198+
}
199+
return out
200+
case []contracts.JSONSchema:
201+
return append([]contracts.JSONSchema(nil), items...)
202+
default:
203+
return nil
204+
}
205+
}
206+
207+
func matchesAtLeastOneSchema(schemas []contracts.JSONSchema, value any, path string) bool {
208+
for _, schema := range schemas {
209+
if err := validateValue(schema, value, path); err == nil {
210+
return true
211+
}
212+
}
213+
return false
214+
}
215+
216+
func countMatchingSchemas(schemas []contracts.JSONSchema, value any, path string) int {
217+
matches := 0
218+
for _, schema := range schemas {
219+
if err := validateValue(schema, value, path); err == nil {
220+
matches++
221+
}
222+
}
223+
return matches
224+
}
225+
114226
func matchesAnyType(types []string, value any) bool {
115227
for _, typ := range types {
116228
switch typ {
@@ -223,6 +335,18 @@ func schemaNumberConstraint(value any) (float64, bool) {
223335
return schemaNumber(value)
224336
}
225337

338+
func exclusiveNumberConstraint(value any, pairedLimit any) (float64, bool) {
339+
switch typed := value.(type) {
340+
case bool:
341+
if !typed {
342+
return 0, false
343+
}
344+
return schemaNumberConstraint(pairedLimit)
345+
default:
346+
return schemaNumberConstraint(value)
347+
}
348+
}
349+
226350
func describeSchemaNumber(value float64) string {
227351
if math.Trunc(value) == value {
228352
return fmt.Sprintf("%.0f", value)

internal/tool/tool_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,86 @@ func TestValidateSchema(t *testing.T) {
7979
}
8080
}
8181

82+
func TestValidateSchemaAdvancedConstraints(t *testing.T) {
83+
schema := contracts.JSONSchema{
84+
"type": "object",
85+
"minProperties": 2,
86+
"maxProperties": 8,
87+
"properties": map[string]any{
88+
"kind": map[string]any{"const": "task"},
89+
"name": map[string]any{
90+
"type": "string",
91+
"maxLength": 8,
92+
"pattern": "^[a-z][a-z0-9-]*$",
93+
},
94+
"ratio": map[string]any{
95+
"type": "number",
96+
"exclusiveMinimum": 0,
97+
"exclusiveMaximum": 1,
98+
},
99+
"legacy_ratio": map[string]any{
100+
"type": "number",
101+
"minimum": 0,
102+
"exclusiveMinimum": true,
103+
},
104+
"tags": map[string]any{
105+
"type": "array",
106+
"minItems": 1,
107+
"maxItems": 2,
108+
"items": map[string]any{"type": "string"},
109+
},
110+
"choice": map[string]any{
111+
"anyOf": []contracts.JSONSchema{
112+
{"type": "string", "enum": []any{"fast", "safe"}},
113+
{"type": "integer", "minimum": 1, "maximum": 3},
114+
},
115+
},
116+
"single": map[string]any{
117+
"oneOf": []map[string]any{
118+
map[string]any{"type": "string"},
119+
map[string]any{"enum": []any{"same"}},
120+
},
121+
},
122+
"combined": map[string]any{
123+
"allOf": []any{
124+
map[string]any{"type": "string"},
125+
map[string]any{"minLength": 3},
126+
},
127+
},
128+
},
129+
}
130+
cases := []struct {
131+
name string
132+
input string
133+
want string
134+
}{
135+
{"min-properties", `{"kind":"task"}`, "input must contain at least 2 properties"},
136+
{"max-properties", `{"kind":"task","name":"alpha","ratio":0.5,"tags":["x"],"choice":"fast","single":"x","combined":"abc","extra":1,"extra2":2}`, "input must contain at most 8 properties"},
137+
{"const", `{"kind":"job","name":"alpha"}`, "input.kind must be task"},
138+
{"pattern", `{"kind":"task","name":"Alpha"}`, "input.name must match pattern ^[a-z][a-z0-9-]*$"},
139+
{"max-length", `{"kind":"task","name":"alpha-beta"}`, "input.name must be at most 8 characters"},
140+
{"exclusive-minimum", `{"kind":"task","ratio":0}`, "input.ratio must be greater than 0"},
141+
{"exclusive-maximum", `{"kind":"task","ratio":1}`, "input.ratio must be less than 1"},
142+
{"legacy-exclusive-minimum", `{"kind":"task","legacy_ratio":0}`, "input.legacy_ratio must be greater than 0"},
143+
{"min-items", `{"kind":"task","tags":[]}`, "input.tags must contain at least 1 items"},
144+
{"max-items", `{"kind":"task","tags":["a","b","c"]}`, "input.tags must contain at most 2 items"},
145+
{"any-of", `{"kind":"task","choice":9}`, "input.choice must match at least one allowed schema"},
146+
{"one-of", `{"kind":"task","single":"same"}`, "input.single must match exactly one allowed schema"},
147+
{"all-of", `{"kind":"task","combined":"ab"}`, "input.combined must be at least 3 characters"},
148+
}
149+
for _, tc := range cases {
150+
t.Run(tc.name, func(t *testing.T) {
151+
err := ValidateSchema(schema, json.RawMessage(tc.input))
152+
if err == nil || !strings.Contains(err.Error(), tc.want) {
153+
t.Fatalf("err = %v, want %q", err, tc.want)
154+
}
155+
})
156+
}
157+
if err := ValidateSchema(schema, json.RawMessage(`{"kind":"task","name":"alpha","ratio":0.5,"legacy_ratio":0.5,"tags":["a","b"],"choice":2,"single":"x","combined":"abc"}`)); err != nil {
158+
t.Fatal(err)
159+
}
160+
}
161+
82162
func TestExecutorRunsAllowedTool(t *testing.T) {
83163
engine := permissions.NewEngine(contracts.PermissionContext{Mode: contracts.PermissionDefault})
84164
registry, err := NewRegistry(FuncTool{

0 commit comments

Comments
 (0)