Skip to content

Commit c296f52

Browse files
author
SqlRush
committed
Validate schema additional properties
1 parent b734ad9 commit c296f52

5 files changed

Lines changed: 64 additions & 28 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ M5 补充:通用 tool schema validator 现在支持 `enum`,可直接执行 G
291291

292292
M5 补充:通用 tool schema validator 现在支持数字 `minimum`/`maximum`,可直接执行 LSPDiagnostics `limit` 等工具 schema 的数值范围契约。
293293

294+
M5/M9 补充:通用 tool schema validator 现在支持 `required` 的 Go `[]string` 形态和 object `additionalProperties` schema 校验,MCP `get_prompt.arguments` 会在 schema 层拒绝非字符串参数值。
295+
294296
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。
295297

296298
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
@@ -186,6 +186,7 @@ test/parity/ # golden tests against TS/official behavior
186186
- 本轮补充:WebSearch domain filters 现在在 schema 层声明 array `items:string`,通用 tool schema validator 同步支持 `items` 校验;`allowed_domains`/`blocked_domains` 会拒绝空字符串、URL/port、非法 wildcard 和非域名 label。
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 的数值范围契约。
189+
- 本轮补充:通用 tool schema validator 现在支持 `required` 的 Go `[]string` 形态和 object `additionalProperties` schema 校验,MCP `get_prompt.arguments` 会在 schema 层拒绝非字符串参数值。
189190
- 本轮补充:WebFetch/WebSearch 输入解码现在兼容 `timeout``max_bytes`/`maxBytes``max_results`/`maxResults` 的 quoted semantic string 数值;WebSearch 也会按官方校验拒绝同一请求同时设置 `allowed_domains``blocked_domains`
190191
- 本轮补充:Grep 现在支持 whole-word 搜索参数 `word_regexp`/`wordRegexp`/`word-regexp`/`-w`,在 regex 和 fixed-string 模式下按词边界过滤匹配,并兼容 quoted boolean 输入。
191192
- 本轮补充:Grep 现在支持反向匹配参数 `invert_match`/`invertMatch`/`invert-match`/`-v``files_with_matches``content``count` 和 multiline 模式都会按非匹配行/未覆盖行输出,并兼容 quoted boolean 输入。

internal/mcp/tools_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,14 @@ func TestBuildPromptToolsListAndGetPrompt(t *testing.T) {
370370
if !strings.Contains(string(client.calls[0].Input), `"name":"deploy"`) || !strings.Contains(string(client.calls[0].Input), `"env":"prod"`) {
371371
t.Fatalf("get input = %s", client.calls[0].Input)
372372
}
373+
_, err = executor.Execute(tool.Context{Context: context.Background()}, contracts.ToolUse{
374+
ID: "toolu_bad_prompt_argument",
375+
Name: "mcp__workflow__get_prompt",
376+
Input: json.RawMessage(`{"name":"deploy","arguments":{"env":3}}`),
377+
}, nil)
378+
if err == nil || !strings.Contains(err.Error(), "input.arguments.env must be string") {
379+
t.Fatalf("expected prompt argument schema error, got %v", err)
380+
}
373381
_, err = executor.Execute(tool.Context{Context: context.Background()}, contracts.ToolUse{
374382
ID: "toolu_bad_prompt",
375383
Name: "mcp__workflow__get_prompt",

internal/tool/schema.go

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -73,24 +73,39 @@ func validateValue(schema contracts.JSONSchema, value any, path string) error {
7373
}
7474

7575
properties, ok := schema["properties"].(map[string]any)
76-
if !ok {
77-
return nil
78-
}
7976
obj, ok := value.(map[string]any)
8077
if !ok {
8178
return nil
8279
}
83-
for key, propertySchema := range properties {
84-
child, ok := obj[key]
85-
if !ok {
86-
continue
80+
if ok {
81+
for key, propertySchema := range properties {
82+
child, ok := obj[key]
83+
if !ok {
84+
continue
85+
}
86+
propertyMap, ok := propertySchema.(map[string]any)
87+
if !ok {
88+
continue
89+
}
90+
if err := validateValue(contracts.JSONSchema(propertyMap), child, path+"."+key); err != nil {
91+
return err
92+
}
8793
}
88-
propertyMap, ok := propertySchema.(map[string]any)
89-
if !ok {
90-
continue
94+
}
95+
if additionalSchema, ok := schema["additionalProperties"].(map[string]any); ok {
96+
for key, child := range obj {
97+
if _, defined := properties[key]; defined {
98+
continue
99+
}
100+
if err := validateValue(contracts.JSONSchema(additionalSchema), child, path+"."+key); err != nil {
101+
return err
102+
}
91103
}
92-
if err := validateValue(contracts.JSONSchema(propertyMap), child, path+"."+key); err != nil {
93-
return err
104+
} else if additional, ok := schema["additionalProperties"].(bool); ok && !additional {
105+
for key := range obj {
106+
if _, defined := properties[key]; !defined {
107+
return fmt.Errorf("%s.%s is not allowed", path, key)
108+
}
94109
}
95110
}
96111
return nil
@@ -253,17 +268,20 @@ func stringOrStrings(value any) []string {
253268
}
254269

255270
func stringSlice(value any) []string {
256-
items, ok := value.([]any)
257-
if !ok {
258-
return nil
259-
}
260-
out := make([]string, 0, len(items))
261-
for _, item := range items {
262-
if s, ok := item.(string); ok {
263-
out = append(out, s)
271+
switch items := value.(type) {
272+
case []any:
273+
out := make([]string, 0, len(items))
274+
for _, item := range items {
275+
if s, ok := item.(string); ok {
276+
out = append(out, s)
277+
}
264278
}
279+
return out
280+
case []string:
281+
return append([]string(nil), items...)
282+
default:
283+
return nil
265284
}
266-
return out
267285
}
268286

269287
func normalizeRawInput(raw json.RawMessage) json.RawMessage {

internal/tool/tool_test.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,19 @@ func TestRegistryAliasLookup(t *testing.T) {
3434
func TestValidateSchema(t *testing.T) {
3535
schema := contracts.JSONSchema{
3636
"type": "object",
37-
"required": []any{"path"},
37+
"required": []string{"path"},
3838
"properties": map[string]any{
39-
"path": map[string]any{"type": "string", "minLength": 2},
40-
"mode": map[string]any{"type": "string", "enum": []any{"read", "write"}},
41-
"count": map[string]any{"type": "integer", "enum": []any{1, 2}},
42-
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 5},
43-
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
39+
"path": map[string]any{"type": "string", "minLength": 2},
40+
"mode": map[string]any{"type": "string", "enum": []any{"read", "write"}},
41+
"count": map[string]any{"type": "integer", "enum": []any{1, 2}},
42+
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 5},
43+
"metadata": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
44+
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
4445
},
4546
}
47+
if err := ValidateSchema(schema, json.RawMessage(`{}`)); err == nil || !strings.Contains(err.Error(), "input.path is required") {
48+
t.Fatalf("err = %v", err)
49+
}
4650
if err := ValidateSchema(schema, json.RawMessage(`{"path":3}`)); err == nil {
4751
t.Fatalf("expected schema validation error")
4852
}
@@ -64,10 +68,13 @@ func TestValidateSchema(t *testing.T) {
6468
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","limit":6}`)); err == nil || !strings.Contains(err.Error(), "input.limit must be at most 5") {
6569
t.Fatalf("err = %v", err)
6670
}
71+
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","metadata":{"env":3}}`)); err == nil || !strings.Contains(err.Error(), "input.metadata.env must be string") {
72+
t.Fatalf("err = %v", err)
73+
}
6774
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md"}`)); err != nil {
6875
t.Fatal(err)
6976
}
70-
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","mode":"read","count":2,"limit":5}`)); err != nil {
77+
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","mode":"read","count":2,"limit":5,"metadata":{"env":"prod"}}`)); err != nil {
7178
t.Fatal(err)
7279
}
7380
}

0 commit comments

Comments
 (0)