Files
玉澜andCursor 54358e1195 fix(schema): restore source_hash content validation on decode path
Re-enable snapshot.SourceHash vs schemaCatalogSnapshotHash compare in
loadSchemaCatalogSnapshot so tampered serialized catalogs fail closed.
Runtime assembly via assembleSchemaCatalogFromRoot still bypasses decode.
Adjust coverage tests to stamp valid hashes and avoid mutating the cached
delivery snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 16:41:40 +08:00

390 lines
15 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 (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const SchemaCatalogSnapshotVersion = 1
// Schema Catalog delivery is single-track: RegisterSchemaSourceRoot →
// ResolveSchemaBuild (see schema_source_root.go). There is no committed
// schema_catalog/ embed and no shard/envelope loading path left in the CLI;
// cmd_schema_catalog owns shard writing and re-merges its own dumps.
// SchemaCatalogSnapshot is the release-stable Agent contract. Catalog holds
// the progressive product/tool index; Tools holds full leaf parameter schemas.
// It intentionally contains no endpoint, credential, or runtime cache data.
type SchemaCatalogSnapshot struct {
Version int `json:"version"`
SourceHash string `json:"source_hash"`
SurfaceHash string `json:"surface_hash,omitempty"`
Catalog map[string]any `json:"catalog"`
Tools map[string]map[string]any `json:"tools"`
}
// SchemaCatalogBuildOptions carries release-envelope inputs which are checked
// against the effective reviewed CommandRegistry. The command set is not an
// option: visibility is resolved by EffectiveCommandRegistry before assembly,
// and every public command must be delivered.
type SchemaCatalogBuildOptions struct {
RegistryHash string
}
type loadedSchemaCatalog struct {
Snapshot SchemaCatalogSnapshot
Registry SchemaRegistry
Index SchemaIndex
}
func registryToSnapshotPayload(registry SchemaRegistry) (SchemaCatalogSnapshot, error) {
payload, err := registry.ToSnapshotPayload()
if err != nil {
return SchemaCatalogSnapshot{}, err
}
return SchemaCatalogSnapshot{Catalog: payload.Catalog, Tools: payload.Tools}, nil
}
var registryToSnapshotPayloadFn = registryToSnapshotPayload
var (
buildCatalogValidateParameterBindings = ValidateSchemaParameterBindingDelivery
buildCatalogValidateDryRun = ValidateReviewedDryRunCapabilityDelivery
buildCatalogValidateExamples = ValidateAgentExampleDelivery
buildCatalogValidateCompleteness = validateResolvedRuntimeSchemaCompleteness
buildCatalogValidateRegistry = validateSchemaRegistryAgainstCommandRegistry
buildCatalogValidateInterfaces = validateSchemaRegistryInterfaces
buildCatalogValidateAgentMetadata = validateSchemaRegistryAgentMetadata
buildCatalogValidateProvenance = validateFinalSchemaProvenanceCoverage
buildCatalogValidateDelivery = ValidateSchemaDeliveryInvariants
buildCatalogValidateFinalCompleteness = validateResolvedSchemaCatalogDeliveryCompleteness
loadCatalogValidateInterfaces = validateSchemaRegistryInterfaces
loadCatalogValidateProvenance = validateFinalSchemaProvenanceCoverage
renderDeliverySchemaAll = func(registry SchemaRegistry) (map[string]any, error) { return registry.ToPayload() }
renderDeliverySchemaOverview = func(registry SchemaRegistry) (map[string]any, error) { return registry.ToOverviewPayload() }
renderSchemaProductSummary = func(product ProductSpec) (map[string]any, error) { return product.ToSummaryPayload() }
renderSchemaToolSummary = func(tool ToolSpec) (map[string]any, error) { return tool.ToSummaryPayload() }
)
// BuildSchemaCatalogSnapshot renders a deterministic Catalog from one
// resolved source-to-delivery hand-off. It deliberately accepts no Cobra root:
// rebuilding SchemaRegistry or re-deriving identity at this boundary would
// allow generation gates to validate one candidate while publishing another.
func BuildSchemaCatalogSnapshot(resolved ResolvedSchemaBuild, options SchemaCatalogBuildOptions) (SchemaCatalogSnapshot, error) {
if resolved.root == nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("schema Catalog source was not created by ResolveSchemaBuild")
}
registry := resolved.registry
effectiveCommands := resolved.effective
registryHash := strings.TrimSpace(options.RegistryHash)
if registryHash == "" {
registryHash = effectiveCommands.SourceHash()
} else if registryHash != effectiveCommands.SourceHash() {
return SchemaCatalogSnapshot{}, fmt.Errorf("provided Registry hash %q disagrees with effective CommandRegistry %q", registryHash, effectiveCommands.SourceHash())
}
if err := buildCatalogValidateParameterBindings(resolved.bound, registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema parameter binding delivery: %w", err)
}
if err := buildCatalogValidateDryRun(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate reviewed dry-run capability delivery: %w", err)
}
if _, err := buildCatalogValidateExamples(resolved.bound, registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Manual Agent example delivery: %w", err)
}
if err := buildCatalogValidateCompleteness(resolved.root, resolved.bound); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate reverse command-tree completeness: %w", err)
}
if err := buildCatalogValidateRegistry(registry, effectiveCommands); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate typed Schema registry against reviewed CommandRegistry: %w", err)
}
if err := buildCatalogValidateInterfaces(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema interface disposition: %w", err)
}
if err := buildCatalogValidateAgentMetadata(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema Agent metadata set: %w", err)
}
if err := buildCatalogValidateProvenance(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema provenance: %w", err)
}
// Visibility has already selected the complete public set in
// collectRuntimeSchemaEntriesFromBound. Do not apply a post-assembly
// allowlist: doing so could silently erase an otherwise valid reviewed
// manual-only command after the exact-set validation above has passed.
registry.Source = SchemaSourceRuntimeAssembled
payload, err := registry.ToSnapshotPayload()
if err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("serialize typed Schema registry: %w", err)
}
snapshot := SchemaCatalogSnapshot{
Version: SchemaCatalogSnapshotVersion,
SurfaceHash: registryHash,
Catalog: payload.Catalog,
Tools: payload.Tools,
}
snapshot.SourceHash = schemaCatalogSnapshotHash(snapshot)
if err := buildCatalogValidateDelivery(registry, snapshot); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema delivery invariants: %w", err)
}
if err := buildCatalogValidateFinalCompleteness(resolved.root, resolved.bound, snapshot); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Catalog delivery completeness: %w", err)
}
return snapshot, nil
}
// decodeSchemaCatalogSnapshot decodes a single-document snapshot and loads it
// through loadSchemaCatalogSnapshot. Delivery validation round-trips generated
// JSON through this function so it cannot pass with data that the shared
// loader would reject.
func decodeSchemaCatalogSnapshot(data []byte) (loadedSchemaCatalog, error) {
var snapshot SchemaCatalogSnapshot
if err := decodeStrictSchemaJSON(data, &snapshot); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("decode Schema Catalog snapshot: %w", err)
}
return loadSchemaCatalogSnapshot(snapshot)
}
// loadSchemaCatalogSnapshot constructs the production lookup from an
// arbitrary decoded snapshot. It validates the progressive summaries against
// the full leaf store before publishing any lookup key.
func loadSchemaCatalogSnapshot(snapshot SchemaCatalogSnapshot) (loadedSchemaCatalog, error) {
if snapshot.Version != SchemaCatalogSnapshotVersion {
return loadedSchemaCatalog{}, fmt.Errorf("unsupported Schema Catalog snapshot version %d", snapshot.Version)
}
if len(snapshot.Catalog) == 0 || len(snapshot.Tools) == 0 {
return loadedSchemaCatalog{}, fmt.Errorf("schema Catalog snapshot is empty")
}
if snapshot.SourceHash == "" {
return loadedSchemaCatalog{}, fmt.Errorf("schema Catalog snapshot is missing source_hash")
}
if snapshot.SourceHash != schemaCatalogSnapshotHash(snapshot) {
return loadedSchemaCatalog{}, fmt.Errorf("schema Catalog snapshot source_hash does not match its content")
}
// Production delivery assembles in memory via assembleSchemaCatalogFromRoot and
// never round-trips through this decode loader. This hash check applies to
// serialized snapshots only (CI round-trips, cmd_schema_catalog dumps, and
// delivery-invariant gates) so tampered catalog_hash cannot be stamped from
// a forged source_hash.
registry, index, err := schemaRegistryFromSnapshot(snapshot)
if err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("load typed Schema registry: %w", err)
}
if err := loadCatalogValidateInterfaces(registry); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("validate final Schema interface disposition: %w", err)
}
// The production loader validates delivered provenance exactly as encoded.
// ToolSpecFromRuntime deliberately does not synthesize candidates or
// rewrite winners, and this coverage gate applies to every snapshot source.
if err := loadCatalogValidateProvenance(registry); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("validate final Schema provenance: %w", err)
}
// Agent metadata is no longer a shipped embed. CI/local cmd_schema_catalog
// dumps may still inject Agent metadata for validation; production delivery
// reassembles via ResolveSchemaBuild and must not reopen retired
// schema_agent_metadata/ artifacts.
return loadedSchemaCatalog{Snapshot: snapshot, Registry: registry, Index: index}, nil
}
func deliverySchemaAllPayload() (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaAllPayloadFromLoaded(deliverySchemaCatalog())
}
func deliverySchemaOverviewPayload() (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaOverviewPayloadFromLoaded(deliverySchemaCatalog())
}
// stampSnapshotHashes writes the protected catalog_hash / surface_hash pair
// onto a delivery payload. Every envelope that exposes snapshot hashes must
// go through this helper so the two keys never drift apart.
func stampSnapshotHashes(payload map[string]any, loaded loadedSchemaCatalog) {
payload["catalog_hash"] = loaded.Snapshot.SourceHash
if loaded.Snapshot.SurfaceHash != "" {
payload["surface_hash"] = loaded.Snapshot.SurfaceHash
}
}
func schemaAllPayloadFromLoaded(loaded loadedSchemaCatalog) (map[string]any, error) {
payload, err := renderDeliverySchemaAll(loaded.Registry)
if err != nil {
return nil, err
}
stampSnapshotHashes(payload, loaded)
return payload, nil
}
func schemaOverviewPayloadFromLoaded(loaded loadedSchemaCatalog) (map[string]any, error) {
payload, err := renderDeliverySchemaOverview(loaded.Registry)
if err != nil {
return nil, err
}
stampSnapshotHashes(payload, loaded)
return payload, nil
}
// queryDeliverySchemaPayload serves dws schema queries through the production
// delivery loader. Completeness / invariant gates must call
// schemaPayloadFromLoadedCatalog (or the deliverySchemaPayload var alias) with
// an explicit loaded catalog — never this helper — to avoid an init cycle
// through assembleSchemaCatalogFromRoot → BuildSchemaCatalogSnapshot.
func queryDeliverySchemaPayload(args []string) (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaPayloadFromLoadedCatalog(deliverySchemaCatalog(), args)
}
// schemaPayloadFromLoadedCatalog is shared by the shipped schema command and
// the final-delivery gate. Keeping lookup and payload rendering on one path
// prevents generation-only validation from accepting an unqueryable snapshot.
func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (map[string]any, error) {
if len(args) == 0 {
snapshot, err := loaded.Registry.ToSnapshotPayload()
if err != nil {
return nil, err
}
payload := snapshot.Catalog
stampSnapshotHashes(payload, loaded)
return payload, nil
}
raw := strings.TrimSpace(args[0])
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
return schemaToolForResolvedPath(tool, raw).ToPayload()
}
tokens := splitSchemaPathTokens(raw)
if len(tokens) == 1 {
if product, ok := loaded.Index.Product(tokens[0]); ok {
payload, err := renderSchemaProductSummary(product)
if err != nil {
return nil, err
}
source := strings.TrimSpace(loaded.Registry.Source)
if source == "" {
source = SchemaSourceRuntimeAssembled
}
return map[string]any{
"kind": "schema",
"level": "product",
"count": len(product.Tools),
"product": payload,
"source": source,
}, nil
}
}
if len(tokens) > 1 {
path := strings.Join(tokens, " ")
if product, ok := loaded.Index.Product(tokens[0]); ok {
matched := make([]map[string]any, 0)
for _, tool := range product.Tools {
if schemaToolUnderGroup(tool, path) {
summary, err := renderSchemaToolSummary(tool)
if err != nil {
return nil, err
}
matched = append(matched, summary)
}
}
if len(matched) > 0 {
source := strings.TrimSpace(loaded.Registry.Source)
if source == "" {
source = SchemaSourceRuntimeAssembled
}
return map[string]any{
"kind": "schema",
"level": "group",
"path": path,
"count": len(matched),
"tools": matched,
"source": source,
}, nil
}
}
}
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw))
}
func schemaCatalogSnapshotHash(snapshot SchemaCatalogSnapshot) string {
payload := struct {
Version int `json:"version"`
SurfaceHash string `json:"surface_hash,omitempty"`
Catalog map[string]any `json:"catalog"`
Tools map[string]map[string]any `json:"tools"`
}{snapshot.Version, snapshot.SurfaceHash, snapshot.Catalog, snapshot.Tools}
encoded, _ := json.Marshal(payload)
sum := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(sum[:])
}
func schemaMapSlice(value any) []map[string]any {
switch values := value.(type) {
case []map[string]any:
return values
case []any:
out := make([]map[string]any, 0, len(values))
for _, value := range values {
if item, ok := value.(map[string]any); ok {
out = append(out, item)
}
}
return out
default:
return nil
}
}
func schemaString(value any) string {
valueString, _ := value.(string)
return valueString
}
func schemaStringSlice(value any) []string {
switch values := value.(type) {
case []string:
return values
case []any:
out := make([]string, 0, len(values))
for _, value := range values {
if item, ok := value.(string); ok {
out = append(out, item)
}
}
return out
default:
return nil
}
}
func firstNonEmptySchemaString(values ...any) string {
for _, value := range values {
if text := strings.TrimSpace(schemaString(value)); text != "" {
return text
}
}
return ""
}