#!/usr/bin/env python3 """Validate the agent context manifest with the Python standard library.""" from __future__ import annotations import json import re import sys from pathlib import Path, PurePosixPath from typing import Any ROOT = Path(__file__).resolve().parents[1] MANIFEST = ROOT / "docs" / "agent-context.json" EXPECTED_SCHEMA = "docs/agent-context.schema.json" REQUIRED_TOP_LEVEL = { "schema", "schema_version", "authority", "bootstrap", "routes", "tasks", "refresh", "degraded_mode", } REQUIRED_BOOTSTRAP = { "AGENTS.md", "docs/00-ai-start-here.md", "docs/05-coding-rules.md", "docs/current-state.md", } TASK_PATH_KEYS = {"roadmap", "directory", "template"} SENSITIVE_KEY = re.compile(r"(?:token|password|secret|credential)", re.IGNORECASE) URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") def load_json(path: Path, errors: list[str]) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError: errors.append(f"文件不存在:{path.relative_to(ROOT).as_posix()}") except json.JSONDecodeError as exc: errors.append( f"JSON 语法错误:{path.relative_to(ROOT).as_posix()}:{exc.lineno}:{exc.colno}" ) return None def require_mapping(value: Any, name: str, errors: list[str]) -> dict[str, Any]: if not isinstance(value, dict): errors.append(f"{name} 必须是对象。") return {} return value def require_string_list(value: Any, name: str, errors: list[str]) -> list[str]: if not isinstance(value, list) or not value or not all( isinstance(item, str) and item for item in value ): errors.append(f"{name} 必须是非空字符串数组。") return [] if len(value) != len(set(value)): errors.append(f"{name} 不得包含重复路径。") return value def validate_repo_path(value: str, name: str, errors: list[str]) -> None: path = PurePosixPath(value) if ( path.is_absolute() or ".." in path.parts or "\\" in value or URI_SCHEME.match(value) ): errors.append(f"{name} 必须是安全的仓库相对路径:{value}") return target = ROOT.joinpath(*path.parts) if not target.exists(): errors.append(f"{name} 引用路径不存在:{value}") def find_sensitive_keys(value: Any, location: str, errors: list[str]) -> None: if isinstance(value, dict): for key, child in value.items(): child_location = f"{location}.{key}" if SENSITIVE_KEY.search(key): errors.append(f"清单不得保存敏感配置字段:{child_location}") find_sensitive_keys(child, child_location, errors) elif isinstance(value, list): for index, child in enumerate(value): find_sensitive_keys(child, f"{location}[{index}]", errors) def main() -> int: errors: list[str] = [] manifest = load_json(MANIFEST, errors) schema = load_json(ROOT / EXPECTED_SCHEMA, errors) if manifest is None or schema is None: return report(errors) if not isinstance(schema, dict) or schema.get("type") != "object": errors.append("agent-context.schema.json 不是有效的对象 Schema。") root = require_mapping(manifest, "manifest", errors) actual_keys = set(root) missing = sorted(REQUIRED_TOP_LEVEL - actual_keys) unexpected = sorted(actual_keys - REQUIRED_TOP_LEVEL) if missing: errors.append("缺少顶层字段:" + ", ".join(missing)) if unexpected: errors.append("存在未知顶层字段:" + ", ".join(unexpected)) if root.get("schema") != EXPECTED_SCHEMA: errors.append(f"schema 必须是 {EXPECTED_SCHEMA}。") if root.get("schema_version") != 1: errors.append("schema_version 必须为 1。") authority = require_mapping(root.get("authority"), "authority", errors) for key in ("bootstrap", "framework_templates", "project_facts", "coordination"): if not isinstance(authority.get(key), str) or not authority[key]: errors.append(f"authority.{key} 必须是非空字符串。") bootstrap = require_mapping(root.get("bootstrap"), "bootstrap", errors) always_read = require_string_list( bootstrap.get("always_read"), "bootstrap.always_read", errors ) missing_bootstrap = sorted(REQUIRED_BOOTSTRAP - set(always_read)) if missing_bootstrap: errors.append("bootstrap.always_read 缺少:" + ", ".join(missing_bootstrap)) routes = require_mapping(root.get("routes"), "routes", errors) if not routes: errors.append("routes 至少需要一个任务类型。") path_values: list[tuple[str, str]] = [(EXPECTED_SCHEMA, "schema")] path_values.extend((path, "bootstrap.always_read") for path in always_read) for route, value in routes.items(): paths = require_string_list(value, f"routes.{route}", errors) path_values.extend((path, f"routes.{route}") for path in paths) tasks = require_mapping(root.get("tasks"), "tasks", errors) if set(tasks) != TASK_PATH_KEYS: errors.append("tasks 必须且只能包含 roadmap、directory、template。") for key in sorted(TASK_PATH_KEYS): value = tasks.get(key) if isinstance(value, str) and value: path_values.append((value, f"tasks.{key}")) else: errors.append(f"tasks.{key} 必须是非空字符串。") refresh = require_mapping(root.get("refresh"), "refresh", errors) expected_refresh = { "context_ref": "default_branch_head_sha", "cache_key": "file_sha", "unchanged_file": "reuse_within_current_session", "changed_ref": "reread_manifest_and_routed_documents", } if refresh != expected_refresh: errors.append("refresh 必须使用约定的提交 SHA 与文件 SHA 刷新策略。") degraded = require_mapping(root.get("degraded_mode"), "degraded_mode", errors) expected_degraded = { "continue_claimed_task": True, "claim_new_task": False, "write_remote_state": False, } if degraded != expected_degraded: errors.append("degraded_mode 必须禁止领取新任务和写入远端状态。") for value, name in path_values: validate_repo_path(value, name, errors) find_sensitive_keys(root, "manifest", errors) if errors: return report(errors) unique_paths = {value for value, _ in path_values} print( "agent-context 校验通过:" f"{len(routes)} 个任务路由,{len(unique_paths)} 个有效仓库路径。" ) return 0 def report(errors: list[str]) -> int: for error in errors: print(f"ERROR: {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())