409 lines
13 KiB
Go
409 lines
13 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
|
)
|
|
|
|
// schema_catalog.json is generated, but every command entry it delivers to
|
|
// Agents must follow one unified closed structure: a fixed required core plus
|
|
// a whitelisted optional set. Any new field must be registered here first,
|
|
// which keeps the delivered command data structure uniform across products.
|
|
// Command identity (names/paths) is collected from ContractFinal.Identity on
|
|
// the live Cobra leaves and is deliberately out of scope for this validator.
|
|
|
|
const schemaCatalogStructureMaxViolations = 25
|
|
|
|
// schemaCatalogToolRequiredKeys is the required core present on every tool
|
|
// entry. Keep sorted; ValidateCatalogStructure rejects entries missing any of
|
|
// these.
|
|
var schemaCatalogToolRequiredKeys = []string{
|
|
"agent_metadata_source",
|
|
"agent_source_refs",
|
|
"agent_summary",
|
|
"agent_summary_source",
|
|
"availability",
|
|
"avoid_when",
|
|
"canonical_path",
|
|
"cli_name",
|
|
"cli_path",
|
|
"confirmation",
|
|
"description",
|
|
"display",
|
|
"effect",
|
|
"effect_source",
|
|
"examples",
|
|
"field_provenance",
|
|
"has_parameters",
|
|
"idempotency",
|
|
"interface_mode",
|
|
"is_alias",
|
|
"name",
|
|
"parameter_count",
|
|
"parameters",
|
|
"path",
|
|
"primary_cli_path",
|
|
"product_id",
|
|
"reviewed",
|
|
"risk",
|
|
"source",
|
|
"title",
|
|
"use_when",
|
|
}
|
|
|
|
// schemaCatalogToolOptionalKeys is the optional whitelist. interface_ref and
|
|
// interface_reason are mutually exclusive and gated by interface_mode.
|
|
var schemaCatalogToolOptionalKeys = []string{
|
|
"aliases",
|
|
"constraints",
|
|
"dry_run",
|
|
"group",
|
|
"interface_reason",
|
|
"interface_ref",
|
|
"metadata_source",
|
|
"pagination",
|
|
"positionals",
|
|
"result",
|
|
}
|
|
|
|
var schemaCatalogToolEnums = map[string][]string{
|
|
"effect": {"read", "write", "destructive"},
|
|
"risk": {"low", "medium", "high"},
|
|
"confirmation": {"not_required", "user_required"},
|
|
"interface_mode": {contract.InterfaceModeMCP, contract.InterfaceModeComposite, contract.InterfaceModeLocal},
|
|
"availability": {contract.InterfaceAvailable, contract.InterfaceUnavailable},
|
|
}
|
|
|
|
// schemaCatalogParamRequiredKeys is the required core of every parameter.
|
|
var schemaCatalogParamRequiredKeys = []string{
|
|
"description",
|
|
"field_provenance",
|
|
"required",
|
|
"type",
|
|
}
|
|
|
|
// schemaCatalogParamOptionalKeys is the parameter optional whitelist.
|
|
var schemaCatalogParamOptionalKeys = []string{
|
|
"cli_required",
|
|
"default",
|
|
"enum",
|
|
"example",
|
|
"format",
|
|
"interface_description",
|
|
"interface_type",
|
|
"property",
|
|
"required_when",
|
|
}
|
|
|
|
var schemaCatalogParamTypes = []string{"string", "integer", "number", "boolean", "array", "object"}
|
|
|
|
type schemaCatalogStructureViolation struct {
|
|
tool string
|
|
message string
|
|
}
|
|
|
|
// ValidateCatalogStructure checks that every tool entry in a schema_catalog
|
|
// snapshot conforms to the unified command data structure. It returns an
|
|
// error aggregating up to schemaCatalogStructureMaxViolations violations.
|
|
func ValidateCatalogStructure(data []byte) error {
|
|
var snapshot struct {
|
|
Version int `json:"version"`
|
|
Tools map[string]map[string]any `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
|
return fmt.Errorf("decode schema catalog: %w", err)
|
|
}
|
|
if snapshot.Version != SchemaCatalogSnapshotVersion {
|
|
return fmt.Errorf("schema catalog version = %d, want %d", snapshot.Version, SchemaCatalogSnapshotVersion)
|
|
}
|
|
if len(snapshot.Tools) == 0 {
|
|
return fmt.Errorf("schema catalog has no tools")
|
|
}
|
|
|
|
violations := []schemaCatalogStructureViolation{}
|
|
for toolID, entry := range snapshot.Tools {
|
|
validateCatalogToolEntry(toolID, entry, &violations)
|
|
}
|
|
if len(violations) == 0 {
|
|
return nil
|
|
}
|
|
sort.Slice(violations, func(i, j int) bool {
|
|
if violations[i].tool != violations[j].tool {
|
|
return violations[i].tool < violations[j].tool
|
|
}
|
|
return violations[i].message < violations[j].message
|
|
})
|
|
var b strings.Builder
|
|
shown := violations
|
|
truncated := 0
|
|
if len(shown) > schemaCatalogStructureMaxViolations {
|
|
truncated = len(shown) - schemaCatalogStructureMaxViolations
|
|
shown = shown[:schemaCatalogStructureMaxViolations]
|
|
}
|
|
for _, v := range shown {
|
|
fmt.Fprintf(&b, "\n %s: %s", v.tool, v.message)
|
|
}
|
|
if truncated > 0 {
|
|
fmt.Fprintf(&b, "\n ... and %d more violations", truncated)
|
|
}
|
|
return fmt.Errorf("schema catalog entries violate the unified command structure (%d total):%s", len(violations), b.String())
|
|
}
|
|
|
|
func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
|
|
report := func(format string, args ...any) {
|
|
*violations = append(*violations, schemaCatalogStructureViolation{
|
|
tool: toolID,
|
|
message: fmt.Sprintf(format, args...),
|
|
})
|
|
}
|
|
|
|
allowed := make(map[string]bool, len(schemaCatalogToolRequiredKeys)+len(schemaCatalogToolOptionalKeys))
|
|
for _, k := range schemaCatalogToolRequiredKeys {
|
|
allowed[k] = true
|
|
}
|
|
for _, k := range schemaCatalogToolOptionalKeys {
|
|
allowed[k] = true
|
|
}
|
|
for key := range entry {
|
|
if !allowed[key] {
|
|
report("unknown field %q (register it in schema_catalog_structure.go first)", key)
|
|
}
|
|
}
|
|
|
|
stringKeys := []string{
|
|
"agent_metadata_source", "agent_summary", "agent_summary_source",
|
|
"availability", "canonical_path", "cli_name", "cli_path", "confirmation",
|
|
"description", "display", "effect", "effect_source", "idempotency",
|
|
"interface_mode", "name", "path", "primary_cli_path", "product_id",
|
|
"risk", "source", "title",
|
|
}
|
|
for _, key := range stringKeys {
|
|
if !requireNonEmptyString(entry, key) {
|
|
report("field %q must be a non-empty string", key)
|
|
}
|
|
}
|
|
stringArrayKeys := []string{"agent_source_refs", "avoid_when", "examples", "use_when"}
|
|
for _, key := range stringArrayKeys {
|
|
if !requireStringArray(entry, key) {
|
|
report("field %q must be an array of strings", key)
|
|
}
|
|
}
|
|
for _, key := range []string{"has_parameters", "is_alias", "reviewed"} {
|
|
if _, ok := entry[key].(bool); !ok {
|
|
report("field %q must be a boolean", key)
|
|
}
|
|
}
|
|
if _, ok := entry["field_provenance"].(map[string]any); !ok {
|
|
report("field %q must be an object", "field_provenance")
|
|
}
|
|
for field, values := range schemaCatalogToolEnums {
|
|
raw, ok := entry[field].(string)
|
|
if !ok {
|
|
continue // already reported as missing/typed above
|
|
}
|
|
if !stringSliceContains(values, raw) {
|
|
report("field %q = %q, want one of {%s}", field, raw, strings.Join(values, ", "))
|
|
}
|
|
}
|
|
|
|
parameters, paramsOK := entry["parameters"].(map[string]any)
|
|
if !paramsOK {
|
|
report("field %q must be an object", "parameters")
|
|
parameters = nil
|
|
}
|
|
count, countOK := entry["parameter_count"].(float64)
|
|
if !countOK {
|
|
report("field %q must be a number", "parameter_count")
|
|
} else if paramsOK && int(count) != len(parameters) {
|
|
report("parameter_count = %d, want %d (len(parameters))", int(count), len(parameters))
|
|
}
|
|
if has, ok := entry["has_parameters"].(bool); ok && paramsOK && has != (len(parameters) > 0) {
|
|
report("has_parameters = %v, want %v (len(parameters) > 0)", has, len(parameters) > 0)
|
|
}
|
|
|
|
validateCatalogInterface(toolID, entry, violations)
|
|
if result, exists := entry["result"]; exists {
|
|
if _, ok := result.(map[string]any); !ok {
|
|
report("field %q must be an object", "result")
|
|
}
|
|
}
|
|
if rawPagination, exists := entry["pagination"]; exists {
|
|
pagination, ok := rawPagination.(map[string]any)
|
|
if !ok {
|
|
report("field %q must be an object", "pagination")
|
|
} else {
|
|
want := map[string]string{
|
|
"kind": contract.PaginationKindCursor,
|
|
"meta_path": contract.PaginationMetaPath,
|
|
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
|
|
"next_token_path": contract.PaginationNextTokenPath,
|
|
}
|
|
for field, expected := range want {
|
|
if value, _ := pagination[field].(string); value != expected {
|
|
report("field %q.%s = %q, want %q", "pagination", field, value, expected)
|
|
}
|
|
}
|
|
cursor, _ := pagination["cursor_parameter"].(string)
|
|
if strings.TrimSpace(cursor) == "" {
|
|
report("field %q.cursor_parameter must be a non-empty string", "pagination")
|
|
} else if paramsOK {
|
|
if _, exists := parameters[cursor]; !exists {
|
|
report("field %q.cursor_parameter references missing parameter %q", "pagination", cursor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for paramName, raw := range parameters {
|
|
param, ok := raw.(map[string]any)
|
|
if !ok {
|
|
report("parameter %q must be an object", paramName)
|
|
continue
|
|
}
|
|
validateCatalogParam(toolID, paramName, param, violations)
|
|
}
|
|
}
|
|
|
|
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
|
|
report := func(format string, args ...any) {
|
|
*violations = append(*violations, schemaCatalogStructureViolation{
|
|
tool: toolID,
|
|
message: fmt.Sprintf(format, args...),
|
|
})
|
|
}
|
|
mode, _ := entry["interface_mode"].(string)
|
|
ref, hasRef := entry["interface_ref"]
|
|
reason, _ := entry["interface_reason"].(string)
|
|
switch mode {
|
|
case contract.InterfaceModeMCP:
|
|
if !hasRef {
|
|
report("interface_mode=mcp requires interface_ref")
|
|
return
|
|
}
|
|
refObj, ok := ref.(map[string]any)
|
|
if !ok {
|
|
report("interface_ref must be an object")
|
|
return
|
|
}
|
|
for _, key := range []string{"product_id", "rpc_name"} {
|
|
if s, ok := refObj[key].(string); !ok || strings.TrimSpace(s) == "" {
|
|
report("interface_ref.%s must be a non-empty string", key)
|
|
}
|
|
}
|
|
if strings.TrimSpace(reason) != "" {
|
|
report("interface_mode=mcp must not set interface_reason")
|
|
}
|
|
case contract.InterfaceModeComposite, contract.InterfaceModeLocal:
|
|
if hasRef {
|
|
report("interface_mode=%s must not set interface_ref", mode)
|
|
}
|
|
if strings.TrimSpace(reason) == "" {
|
|
report("interface_mode=%s requires a non-empty interface_reason", mode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateCatalogParam(toolID, paramName string, param map[string]any, violations *[]schemaCatalogStructureViolation) {
|
|
report := func(format string, args ...any) {
|
|
*violations = append(*violations, schemaCatalogStructureViolation{
|
|
tool: toolID,
|
|
message: fmt.Sprintf(format, args...),
|
|
})
|
|
}
|
|
|
|
allowed := make(map[string]bool, len(schemaCatalogParamRequiredKeys)+len(schemaCatalogParamOptionalKeys))
|
|
for _, k := range schemaCatalogParamRequiredKeys {
|
|
allowed[k] = true
|
|
}
|
|
for _, k := range schemaCatalogParamOptionalKeys {
|
|
allowed[k] = true
|
|
}
|
|
for key := range param {
|
|
if !allowed[key] {
|
|
report("parameter %q: unknown field %q", paramName, key)
|
|
}
|
|
}
|
|
|
|
typ, _ := param["type"].(string)
|
|
if !stringSliceContains(schemaCatalogParamTypes, typ) {
|
|
report("parameter %q: type = %q, want one of {%s}", paramName, typ, strings.Join(schemaCatalogParamTypes, ", "))
|
|
}
|
|
if s, ok := param["description"].(string); !ok || strings.TrimSpace(s) == "" {
|
|
report("parameter %q: description must be a non-empty string", paramName)
|
|
}
|
|
if _, ok := param["required"].(bool); !ok {
|
|
report("parameter %q: required must be a boolean", paramName)
|
|
}
|
|
if _, ok := param["field_provenance"].(map[string]any); !ok {
|
|
report("parameter %q: field_provenance must be an object", paramName)
|
|
}
|
|
|
|
for _, key := range []string{"property", "interface_description", "interface_type", "format", "default", "example", "required_when"} {
|
|
if raw, present := param[key]; present {
|
|
if _, ok := raw.(string); !ok {
|
|
report("parameter %q: %s must be a string", paramName, key)
|
|
}
|
|
}
|
|
}
|
|
if raw, present := param["cli_required"]; present {
|
|
if _, ok := raw.(bool); !ok {
|
|
report("parameter %q: cli_required must be a boolean", paramName)
|
|
}
|
|
}
|
|
if raw, present := param["enum"]; present {
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
report("parameter %q: enum must be an array", paramName)
|
|
} else {
|
|
for i, item := range items {
|
|
if _, ok := item.(string); !ok {
|
|
report("parameter %q: enum[%d] must be a string", paramName, i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func requireNonEmptyString(entry map[string]any, key string) bool {
|
|
s, ok := entry[key].(string)
|
|
return ok && strings.TrimSpace(s) != ""
|
|
}
|
|
|
|
func requireStringArray(entry map[string]any, key string) bool {
|
|
raw, ok := entry[key].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, item := range raw {
|
|
if _, ok := item.(string); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func stringSliceContains(values []string, target string) bool {
|
|
for _, v := range values {
|
|
if v == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|