900 lines
35 KiB
Python
900 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Ensure package imports work both for direct CLI execution and imports.
|
|
_ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
if str(_ROOT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_ROOT_DIR))
|
|
|
|
from scripts import create_manifest # noqa: E402
|
|
from scripts.packlib import ( # noqa: E402
|
|
ROOT,
|
|
PackError,
|
|
load_json,
|
|
load_plan,
|
|
load_state,
|
|
packet_map,
|
|
)
|
|
|
|
REQUIRED_FILES = [
|
|
"README_NL.md",
|
|
"START_HERE.md",
|
|
"AGENTS.md",
|
|
"PROJECT_CONTRACT.md",
|
|
"CURRENT_STATE.md",
|
|
"BUILD_STATE.json",
|
|
"VERSION_BASELINE.yaml",
|
|
"ACCEPTANCE_MATRIX.md",
|
|
"prompts/AUTONOMOUS_BUILD_LUNA.md",
|
|
"implementation/WORK_PLAN.json",
|
|
"docs/02-security/SECURITY_INVARIANTS.md",
|
|
"contracts/schemas/project.schema.json",
|
|
"contracts/schemas/connector-manifest.schema.json",
|
|
"contracts/schemas/tool-manifest.schema.json",
|
|
"contracts/schemas/policy.schema.json",
|
|
"infrastructure/compose/docker-compose.blueprint.yml",
|
|
]
|
|
|
|
KIND_REQUIRED = {
|
|
"Workspace": {"apiVersion", "kind", "metadata", "spec"},
|
|
"Project": {"apiVersion", "kind", "metadata", "spec"},
|
|
"CapabilityPack": {"apiVersion", "kind", "metadata", "spec"},
|
|
"ConnectorTemplate": {"apiVersion", "kind", "metadata", "spec"},
|
|
"Policy": {"apiVersion", "kind", "metadata", "spec"},
|
|
"ToolManifest": {"apiVersion", "kind", "metadata", "spec"},
|
|
"ResourceManifest": {"apiVersion", "kind", "metadata", "spec"},
|
|
"PromptManifest": {"apiVersion", "kind", "metadata", "spec"},
|
|
}
|
|
|
|
FORBIDDEN_TOOL_PREFIXES = (
|
|
"shell.",
|
|
"filesystem.",
|
|
"docker.raw",
|
|
"database.execute",
|
|
"secrets.",
|
|
"http.request",
|
|
)
|
|
|
|
CONTROLLED_GITEA_WRITE_TOOLS = frozenset(
|
|
{
|
|
"gitea.repository.create",
|
|
"gitea.branchprotection.ensure",
|
|
"gitea.portfolio.governance.apply",
|
|
"gitea.branch.create",
|
|
"gitea.issue.create",
|
|
"gitea.issue.update",
|
|
"gitea.issue.comment.create",
|
|
"gitea.file.create",
|
|
"gitea.file.update",
|
|
"gitea.file.delete",
|
|
"gitea.pullrequest.create",
|
|
"gitea.pullrequest.update",
|
|
"gitea.pullrequest.close",
|
|
"gitea.pullrequest.validation.ensure",
|
|
"gitea.changeset.apply",
|
|
"gitea.validation.run",
|
|
"gitea.workspace.run",
|
|
"gitea.orchestration.start",
|
|
"gitea.pullrequest.sync",
|
|
"gitea.pullrequest.merge",
|
|
"gitea.branch.delete",
|
|
}
|
|
)
|
|
|
|
CONTROLLED_PROJECTBRAIN_WRITE_TOOLS = frozenset(
|
|
{
|
|
"projectbrain.brainstorm.save",
|
|
"projectbrain.knowledge.save",
|
|
"projectbrain.documentation.save",
|
|
"projectbrain.memory.save",
|
|
"projectbrain.contextpack.create",
|
|
"projectbrain.feedback.record",
|
|
"projectbrain.delivery.create",
|
|
"projectbrain.delivery.update",
|
|
"projectbrain.delivery.start",
|
|
"projectbrain.delivery.file.write",
|
|
"projectbrain.delivery.file.delete",
|
|
"projectbrain.delivery.changeset.apply",
|
|
"projectbrain.delivery.validation.record",
|
|
"projectbrain.delivery.validation.sync",
|
|
"projectbrain.delivery.complete",
|
|
"projectbrain.delivery.merge",
|
|
"projectbrain.analysis.start",
|
|
"projectbrain.analysis.cancel",
|
|
"projectbrain.finding.issue.sync",
|
|
"projectbrain.finding.investigation.start",
|
|
"projectbrain.finding.investigation.save",
|
|
"projectbrain.finding.suppress",
|
|
"projectbrain.finding.delivery.create",
|
|
"projectbrain.finding.verify",
|
|
"projectbrain.portfolio.repository.reconcile",
|
|
"projectbrain.portfolio.repository.exclusion.set",
|
|
"projectbrain.autonomy.executor.heartbeat",
|
|
"projectbrain.autonomy.configure",
|
|
"projectbrain.autonomy.roadmap.import",
|
|
"projectbrain.autonomy.resume",
|
|
"projectbrain.autonomy.work.claim",
|
|
"projectbrain.autonomy.work.report",
|
|
}
|
|
)
|
|
|
|
CONTROLLED_APPOPS_WRITE_TOOLS = frozenset(
|
|
{
|
|
"server.appops.self_upgrade.apply",
|
|
"server.operation.request",
|
|
"server.operation.execute",
|
|
"server.job.cancel",
|
|
"server.deployment.apply",
|
|
"server.deployment.rollback",
|
|
"server.deployment.target.enroll",
|
|
"server.project.reconcile",
|
|
}
|
|
)
|
|
|
|
CONTROLLED_STITCH_WRITE_TOOLS = frozenset(
|
|
{
|
|
"stitch.project.create",
|
|
"stitch.screen.edit",
|
|
"stitch.screen.generate",
|
|
"stitch.screen.variants",
|
|
}
|
|
)
|
|
|
|
CONTROLLED_OPENCODE_WRITE_TOOLS = frozenset(
|
|
{
|
|
"opencode.workspace.ensure",
|
|
"opencode.job.start",
|
|
"opencode.job.cancel",
|
|
"opencode.delivery.start",
|
|
"opencode.delivery.merge",
|
|
"opencode.delivery.control.start",
|
|
"opencode.delivery.control.retry",
|
|
"opencode.delivery.control.cancel",
|
|
}
|
|
)
|
|
|
|
CONTROLLED_KNOWLEDGE_INGESTION_WRITE_TOOLS = frozenset(
|
|
{
|
|
"knowledge.ingestion.analyze",
|
|
"knowledge.ingestion.text_proposal",
|
|
"knowledge.ingestion.submit",
|
|
}
|
|
)
|
|
|
|
GITEA_LIFECYCLE_TOOLS = frozenset(
|
|
{"gitea.pullrequest.sync", "gitea.pullrequest.merge", "gitea.branch.delete"}
|
|
)
|
|
APPOPS_LIFECYCLE_TOOLS = frozenset(
|
|
{
|
|
"server.appops.self_upgrade.apply",
|
|
"server.operation.execute",
|
|
"server.deployment.apply",
|
|
"server.deployment.rollback",
|
|
"server.deployment.target.enroll",
|
|
"server.project.reconcile",
|
|
}
|
|
)
|
|
OPENCODE_LIFECYCLE_TOOLS = frozenset(
|
|
{
|
|
"opencode.delivery.merge",
|
|
"opencode.delivery.control.cancel",
|
|
}
|
|
)
|
|
|
|
# Strong patterns only; documentation may legitimately contain terms such as token.
|
|
SECRET_PATTERNS = {
|
|
"private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
"GitHub token": re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}\b"),
|
|
"OpenAI key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
|
"JWT-like value": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
}
|
|
|
|
|
|
def add_error(errors: list[str], message: str) -> None:
|
|
errors.append(message)
|
|
|
|
|
|
def validate_required_files(errors: list[str]) -> None:
|
|
for item in REQUIRED_FILES:
|
|
if not (ROOT / item).is_file():
|
|
add_error(errors, f"missing required file: {item}")
|
|
|
|
|
|
def validate_plan_and_state(
|
|
errors: list[str], warnings: list[str]
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
try:
|
|
plan = load_plan()
|
|
state = load_state()
|
|
packets = packet_map(plan)
|
|
except PackError as exc:
|
|
add_error(errors, str(exc))
|
|
return {}, {}
|
|
|
|
state_packets = state.get("packets", {})
|
|
if set(state_packets) != set(packets):
|
|
add_error(
|
|
errors,
|
|
"packet ids differ between WORK_PLAN and BUILD_STATE; "
|
|
f"plan-only={sorted(set(packets) - set(state_packets))}, "
|
|
f"state-only={sorted(set(state_packets) - set(packets))}",
|
|
)
|
|
|
|
allowed_statuses = {"pending", "in_progress", "completed", "blocked", "failed"}
|
|
invalid_status = {
|
|
pid: status for pid, status in state_packets.items() if status not in allowed_statuses
|
|
}
|
|
if invalid_status:
|
|
add_error(errors, f"invalid packet statuses: {invalid_status}")
|
|
|
|
in_progress = [pid for pid, status in state_packets.items() if status == "in_progress"]
|
|
if len(in_progress) > 1:
|
|
add_error(errors, f"at most one packet may be in progress: {in_progress}")
|
|
|
|
graph: dict[str, list[str]] = {}
|
|
for packet_id, packet in packets.items():
|
|
deps = packet.get("deps", [])
|
|
if not isinstance(deps, list) or not all(isinstance(dep, str) for dep in deps):
|
|
add_error(errors, f"{packet_id} deps must be a string array")
|
|
deps = []
|
|
graph[packet_id] = deps
|
|
for dep in deps:
|
|
if dep not in packets:
|
|
add_error(errors, f"{packet_id} references unknown dependency {dep}")
|
|
if dep == packet_id:
|
|
add_error(errors, f"{packet_id} depends on itself")
|
|
|
|
packet_file = ROOT / "implementation" / "work-packets" / f"{packet_id}.md"
|
|
if not packet_file.is_file():
|
|
add_error(
|
|
errors,
|
|
f"missing packet document: {packet_file.relative_to(ROOT)}",
|
|
)
|
|
else:
|
|
first_line = packet_file.read_text(encoding="utf-8").splitlines()[0:1]
|
|
if not first_line or packet_id not in first_line[0]:
|
|
add_error(
|
|
errors,
|
|
"packet document does not identify "
|
|
f"{packet_id}: {packet_file.relative_to(ROOT)}",
|
|
)
|
|
|
|
for ref in packet.get("read", []):
|
|
if not isinstance(ref, str):
|
|
add_error(errors, f"{packet_id} read entry is not a string: {ref!r}")
|
|
continue
|
|
if not (ROOT / ref).is_file():
|
|
add_error(errors, f"{packet_id} read reference does not exist: {ref}")
|
|
|
|
visiting: set[str] = set()
|
|
visited: set[str] = set()
|
|
|
|
def visit(node: str, chain: list[str]) -> None:
|
|
if node in visited:
|
|
return
|
|
if node in visiting:
|
|
add_error(
|
|
errors,
|
|
f"work-packet dependency cycle: {' -> '.join(chain + [node])}",
|
|
)
|
|
return
|
|
visiting.add(node)
|
|
for dep in graph.get(node, []):
|
|
if dep in graph:
|
|
visit(dep, chain + [node])
|
|
visiting.remove(node)
|
|
visited.add(node)
|
|
|
|
for node in graph:
|
|
visit(node, [])
|
|
|
|
if plan.get("default_model") != "gpt-5.6-luna":
|
|
warnings.append("WORK_PLAN default_model is not gpt-5.6-luna")
|
|
return plan, state
|
|
|
|
|
|
def read_catalog_documents(
|
|
errors: list[str],
|
|
) -> list[tuple[Path, dict[str, Any]]]:
|
|
documents: list[tuple[Path, dict[str, Any]]] = []
|
|
for path in sorted((ROOT / "catalog").rglob("*.yaml")):
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
add_error(
|
|
errors,
|
|
"catalog file is not JSON-compatible YAML: "
|
|
f"{path.relative_to(ROOT)} line {exc.lineno}: {exc.msg}",
|
|
)
|
|
continue
|
|
if not isinstance(value, dict):
|
|
add_error(
|
|
errors,
|
|
f"catalog document must be an object: {path.relative_to(ROOT)}",
|
|
)
|
|
continue
|
|
documents.append((path, value))
|
|
return documents
|
|
|
|
|
|
def validate_catalog(errors: list[str], warnings: list[str]) -> dict[str, int]:
|
|
del warnings # reserved for future non-fatal catalog checks
|
|
docs = read_catalog_documents(errors)
|
|
by_kind: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
seen: set[tuple[str, str]] = set()
|
|
|
|
for path, value in docs:
|
|
kind = value.get("kind")
|
|
metadata = value.get("metadata")
|
|
if kind not in KIND_REQUIRED:
|
|
add_error(
|
|
errors,
|
|
f"unknown or missing catalog kind in {path.relative_to(ROOT)}: {kind!r}",
|
|
)
|
|
continue
|
|
missing = KIND_REQUIRED[kind] - set(value)
|
|
if missing:
|
|
add_error(
|
|
errors,
|
|
f"{path.relative_to(ROOT)} missing top-level keys: {sorted(missing)}",
|
|
)
|
|
if value.get("apiVersion") != "hub.example.com/v1alpha1":
|
|
add_error(errors, f"{path.relative_to(ROOT)} has unsupported apiVersion")
|
|
if not isinstance(metadata, dict) or not isinstance(metadata.get("slug"), str):
|
|
add_error(errors, f"{path.relative_to(ROOT)} requires metadata.slug")
|
|
continue
|
|
slug = metadata["slug"]
|
|
key = (kind, slug)
|
|
if key in seen:
|
|
add_error(errors, f"duplicate catalog identity {kind}/{slug}")
|
|
seen.add(key)
|
|
by_kind[kind].append(value)
|
|
|
|
workspaces = {d["metadata"]["slug"] for d in by_kind["Workspace"]}
|
|
packs = {d["metadata"]["slug"] for d in by_kind["CapabilityPack"]}
|
|
connectors = {d["metadata"]["slug"] for d in by_kind["ConnectorTemplate"]}
|
|
endpoint_owner: dict[str, str] = {}
|
|
|
|
for project in by_kind["Project"]:
|
|
slug = project["metadata"]["slug"]
|
|
spec = project.get("spec", {})
|
|
workspace = spec.get("workspace")
|
|
if workspace not in workspaces:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} references unknown workspace {workspace!r}",
|
|
)
|
|
for pack in spec.get("capabilityPacks", []):
|
|
if pack not in packs:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} references unknown capability pack {pack!r}",
|
|
)
|
|
envs = set(spec.get("environments", {}))
|
|
for binding in spec.get("connectorBindings", []):
|
|
ref = binding.get("ref") if isinstance(binding, dict) else None
|
|
if ref not in connectors:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} references unknown connector {ref!r}",
|
|
)
|
|
unknown_envs = (
|
|
set(binding.get("environments", [])) - envs if isinstance(binding, dict) else set()
|
|
)
|
|
if unknown_envs:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} connector {ref!r} references unknown "
|
|
f"environments {sorted(unknown_envs)}",
|
|
)
|
|
for server in spec.get("virtualServers", []):
|
|
if not isinstance(server, dict):
|
|
add_error(errors, f"Project/{slug} virtual server must be an object")
|
|
continue
|
|
endpoint = server.get("endpointAlias")
|
|
env = server.get("environment")
|
|
if env not in envs:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} virtual server references unknown environment {env!r}",
|
|
)
|
|
if endpoint in endpoint_owner:
|
|
add_error(
|
|
errors,
|
|
f"endpoint alias {endpoint!r} reused by {endpoint_owner[endpoint]} and {slug}",
|
|
)
|
|
elif isinstance(endpoint, str):
|
|
endpoint_owner[endpoint] = slug
|
|
for pattern in server.get("toolAllowPatterns", []):
|
|
if isinstance(pattern, str) and pattern.startswith(FORBIDDEN_TOOL_PREFIXES):
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} allows forbidden generic tool pattern {pattern!r}",
|
|
)
|
|
if server.get("defaultDecision") != "deny":
|
|
add_error(errors, f"Project/{slug} virtual server must default deny")
|
|
documentation = spec.get("documentation", {})
|
|
if documentation.get("indexSecrets") is not False:
|
|
add_error(
|
|
errors,
|
|
f"Project/{slug} must set documentation.indexSecrets=false",
|
|
)
|
|
|
|
for manifest in by_kind["ToolManifest"]:
|
|
slug = manifest["metadata"]["slug"]
|
|
spec = manifest.get("spec", {})
|
|
canonical_name = spec.get("canonicalName")
|
|
if not isinstance(canonical_name, str):
|
|
add_error(errors, f"ToolManifest/{slug} requires spec.canonicalName")
|
|
elif canonical_name.startswith(FORBIDDEN_TOOL_PREFIXES):
|
|
add_error(
|
|
errors, f"ToolManifest/{slug} exposes forbidden capability {canonical_name!r}"
|
|
)
|
|
controlled_write = (
|
|
canonical_name in CONTROLLED_GITEA_WRITE_TOOLS
|
|
or canonical_name in CONTROLLED_PROJECTBRAIN_WRITE_TOOLS
|
|
or canonical_name in CONTROLLED_APPOPS_WRITE_TOOLS
|
|
or canonical_name in CONTROLLED_STITCH_WRITE_TOOLS
|
|
or canonical_name in CONTROLLED_OPENCODE_WRITE_TOOLS
|
|
or canonical_name in CONTROLLED_KNOWLEDGE_INGESTION_WRITE_TOOLS
|
|
)
|
|
if spec.get("readOnly") is not True and not controlled_write:
|
|
add_error(errors, f"ToolManifest/{slug} is not an approved controlled write tool")
|
|
expected_risks = (
|
|
{"R3"}
|
|
if canonical_name in GITEA_LIFECYCLE_TOOLS
|
|
or canonical_name in APPOPS_LIFECYCLE_TOOLS
|
|
or canonical_name in OPENCODE_LIFECYCLE_TOOLS
|
|
else ({"R2"} if controlled_write else {"R0", "R1"})
|
|
)
|
|
if spec.get("riskClass") not in expected_risks:
|
|
add_error(
|
|
errors,
|
|
f"ToolManifest/{slug} has invalid risk class {spec.get('riskClass')!r}",
|
|
)
|
|
if not isinstance(spec.get("inputSchema"), dict) or not isinstance(
|
|
spec.get("outputSchema"), dict
|
|
):
|
|
add_error(errors, f"ToolManifest/{slug} requires input and output schemas")
|
|
|
|
if not by_kind["Policy"]:
|
|
add_error(errors, "catalog contains no policies")
|
|
if not any(p["metadata"]["slug"] == "global-deny" for p in by_kind["Policy"]):
|
|
add_error(
|
|
errors,
|
|
"catalog/policies/global-deny.yaml is not represented as Policy/global-deny",
|
|
)
|
|
|
|
return {kind: len(items) for kind, items in sorted(by_kind.items())}
|
|
|
|
|
|
def validate_schemas_and_examples(errors: list[str]) -> tuple[int, int]:
|
|
schema_count = 0
|
|
example_count = 0
|
|
for path in sorted((ROOT / "contracts" / "schemas").glob("*.json")):
|
|
schema_count += 1
|
|
try:
|
|
value = load_json(path)
|
|
except PackError as exc:
|
|
add_error(errors, str(exc))
|
|
continue
|
|
if (
|
|
not isinstance(value, dict)
|
|
or value.get("$schema") != "https://json-schema.org/draft/2020-12/schema"
|
|
):
|
|
add_error(
|
|
errors,
|
|
f"schema missing draft 2020-12 declaration: {path.relative_to(ROOT)}",
|
|
)
|
|
for path in sorted((ROOT / "contracts" / "examples").glob("*.json")):
|
|
example_count += 1
|
|
try:
|
|
load_json(path)
|
|
except PackError as exc:
|
|
add_error(errors, str(exc))
|
|
if schema_count < 6:
|
|
add_error(errors, f"expected at least 6 contract schemas, found {schema_count}")
|
|
return schema_count, example_count
|
|
|
|
|
|
def validate_catalog_json_schemas(errors: list[str], warnings: list[str]) -> None:
|
|
try:
|
|
from jsonschema import Draft202012Validator, FormatChecker # type: ignore[import-untyped]
|
|
from referencing import Registry, Resource
|
|
except ImportError:
|
|
warnings.append(
|
|
"jsonschema/referencing not installed; full instance-schema validation "
|
|
"deferred to WP-010"
|
|
)
|
|
return
|
|
|
|
schema_dir = ROOT / "contracts" / "schemas"
|
|
kind_map = {
|
|
"Workspace": "workspace.schema.json",
|
|
"Project": "project.schema.json",
|
|
"CapabilityPack": "capability-pack.schema.json",
|
|
"ConnectorTemplate": "connector-manifest.schema.json",
|
|
"Policy": "policy.schema.json",
|
|
"ToolManifest": "tool-manifest.schema.json",
|
|
"ResourceManifest": "resource-manifest.schema.json",
|
|
"PromptManifest": "prompt-manifest.schema.json",
|
|
}
|
|
registry = Registry()
|
|
schemas: dict[str, dict[str, Any]] = {}
|
|
for path in sorted(schema_dir.glob("*.json")):
|
|
schema = load_json(path)
|
|
if not isinstance(schema, dict):
|
|
add_error(errors, f"schema is not an object: {path.relative_to(ROOT)}")
|
|
continue
|
|
schemas[path.name] = schema
|
|
resource = Resource.from_contents(schema)
|
|
registry = registry.with_resource(path.resolve().as_uri(), resource)
|
|
if isinstance(schema.get("$id"), str):
|
|
registry = registry.with_resource(schema["$id"], resource)
|
|
|
|
for path, document in read_catalog_documents(errors):
|
|
kind = document.get("kind")
|
|
schema_name = kind_map.get(kind) if isinstance(kind, str) else None
|
|
if not schema_name or schema_name not in schemas:
|
|
continue
|
|
validator = Draft202012Validator(
|
|
schemas[schema_name],
|
|
registry=registry,
|
|
format_checker=FormatChecker(),
|
|
)
|
|
for issue in validator.iter_errors(document):
|
|
location = "/".join(str(item) for item in issue.absolute_path) or "<root>"
|
|
add_error(
|
|
errors,
|
|
f"schema validation failed: {path.relative_to(ROOT)}:{location}: {issue.message}",
|
|
)
|
|
|
|
operation_schema = schemas.get("operation.schema.json")
|
|
for path in sorted((ROOT / "contracts" / "examples").glob("*.json")):
|
|
document = load_json(path)
|
|
if not isinstance(document, dict):
|
|
add_error(errors, f"example is not an object: {path.relative_to(ROOT)}")
|
|
continue
|
|
kind = document.get("kind")
|
|
schema_name = kind_map.get(kind) if isinstance(kind, str) else None
|
|
schema = schemas.get(schema_name) if schema_name else operation_schema
|
|
if schema is None:
|
|
continue
|
|
validator = Draft202012Validator(
|
|
schema,
|
|
registry=registry,
|
|
format_checker=FormatChecker(),
|
|
)
|
|
for issue in validator.iter_errors(document):
|
|
location = "/".join(str(item) for item in issue.absolute_path) or "<root>"
|
|
add_error(
|
|
errors,
|
|
f"example schema validation failed: {path.relative_to(ROOT)}:{location}: "
|
|
f"{issue.message}",
|
|
)
|
|
|
|
|
|
def validate_compose_blueprint(errors: list[str], warnings: list[str]) -> None:
|
|
try:
|
|
import yaml # type: ignore[import-untyped]
|
|
except ImportError:
|
|
warnings.append("PyYAML not installed; Compose YAML parse deferred to WP-020")
|
|
return
|
|
path = ROOT / "infrastructure" / "compose" / "docker-compose.blueprint.yml"
|
|
try:
|
|
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except Exception as exc: # PyYAML exposes several parser subclasses.
|
|
add_error(errors, f"invalid Compose blueprint YAML: {exc}")
|
|
return
|
|
if not isinstance(document, dict) or not isinstance(document.get("services"), dict):
|
|
add_error(errors, "Compose blueprint must contain a services object")
|
|
return
|
|
services = document["services"]
|
|
allowed_publishers = {"web", "dataplane-router", "inspector"}
|
|
for name, service in services.items():
|
|
if not isinstance(service, dict):
|
|
add_error(errors, f"Compose service {name} is not an object")
|
|
continue
|
|
if service.get("ports") and name not in allowed_publishers:
|
|
add_error(errors, f"internal service unexpectedly publishes ports: {name}")
|
|
for volume in service.get("volumes", []) or []:
|
|
serialized = json.dumps(volume) if isinstance(volume, dict) else str(volume)
|
|
if "/var/run/docker.sock" in serialized and name != "docker-read-broker":
|
|
add_error(errors, f"Docker socket mounted outside broker: {name}")
|
|
broker = services.get("docker-read-broker", {})
|
|
broker_volumes = "\n".join(str(item) for item in broker.get("volumes", []))
|
|
if "/var/run/docker.sock:/var/run/docker.sock:ro" not in broker_volumes:
|
|
add_error(errors, "Docker read broker must mount the socket read-only")
|
|
|
|
|
|
def validate_no_obvious_secrets(errors: list[str]) -> None:
|
|
roots = [
|
|
ROOT / "catalog",
|
|
ROOT / "contracts",
|
|
ROOT / "infrastructure",
|
|
ROOT / "prompts",
|
|
ROOT / "scripts",
|
|
]
|
|
for base in roots:
|
|
for path in sorted(p for p in base.rglob("*") if p.is_file()):
|
|
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".zip"}:
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for label, pattern in SECRET_PATTERNS.items():
|
|
if pattern.search(text):
|
|
add_error(errors, f"possible {label} in {path.relative_to(ROOT)}")
|
|
|
|
|
|
def _parse_manifest_entries(
|
|
manifest: dict[str, Any], errors: list[str]
|
|
) -> dict[str, dict[str, Any]]:
|
|
entries = manifest.get("files", [])
|
|
if not isinstance(entries, list):
|
|
add_error(errors, "manifest files must be an array")
|
|
return {}
|
|
self_excluded = manifest.get("selfExcluded", "PACK_MANIFEST.json")
|
|
by_path: dict[str, dict[str, Any]] = {}
|
|
for entry in entries:
|
|
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
add_error(errors, "manifest entry without a string path")
|
|
continue
|
|
rel = entry["path"]
|
|
if rel == self_excluded:
|
|
add_error(errors, f"manifest illegally includes its own excluded path: {rel}")
|
|
continue
|
|
if rel in by_path:
|
|
add_error(errors, f"duplicate manifest path: {rel}")
|
|
continue
|
|
by_path[rel] = entry
|
|
return by_path
|
|
|
|
|
|
def validate_manifest(errors: list[str], warnings: list[str], repo_root: Path = ROOT) -> None:
|
|
"""Validate PACK_MANIFEST.json against the live Git repository at HEAD.
|
|
|
|
Every check here is deliberately Git-native (blob content, digest,
|
|
porcelain status) rather than raw filesystem bytes, so that a Windows
|
|
checkout (CRLF) and a Linux checkout (LF) of the identical commit
|
|
validate identically. See scripts/create_manifest.py and .gitattributes
|
|
for the full rationale.
|
|
"""
|
|
path = repo_root / "PACK_MANIFEST.json"
|
|
if not path.exists():
|
|
warnings.append(
|
|
"PACK_MANIFEST.json not generated yet; run scripts/create_manifest.py before packaging"
|
|
)
|
|
return
|
|
try:
|
|
manifest = load_json(path)
|
|
except PackError as exc:
|
|
add_error(errors, str(exc))
|
|
return
|
|
if not isinstance(manifest, dict):
|
|
add_error(errors, "PACK_MANIFEST.json must be a JSON object")
|
|
return
|
|
|
|
source = manifest.get("source")
|
|
if not isinstance(source, dict) or not isinstance(source.get("contentDigest"), str):
|
|
add_error(
|
|
errors,
|
|
"PACK_MANIFEST.json has no source.contentDigest; regenerate "
|
|
"with scripts/create_manifest.py (schemaVersion 2.0+ required)",
|
|
)
|
|
return
|
|
manifest_digest = source["contentDigest"]
|
|
|
|
entries = manifest.get("files", [])
|
|
if not isinstance(entries, list):
|
|
add_error(errors, "manifest files must be an array")
|
|
return
|
|
by_path = _parse_manifest_entries(manifest, errors)
|
|
|
|
# The recorded top-level digest must itself be a true statement about
|
|
# the "files" array it sits beside — a non-blocking sanity check,
|
|
# reported alongside (not instead of) the itemized comparison below, so
|
|
# a hand-edited individual entry is always named specifically rather
|
|
# than only producing a generic "digest doesn't match" error.
|
|
entries_digest = create_manifest.compute_content_digest(list(by_path.values()))
|
|
if entries_digest != manifest_digest:
|
|
add_error(
|
|
errors,
|
|
"PACK_MANIFEST.json is internally inconsistent: source.contentDigest "
|
|
f"({manifest_digest}) does not match a fresh digest of its own "
|
|
f"files array ({entries_digest}) — the manifest was hand-edited "
|
|
"or corrupted after generation.",
|
|
)
|
|
|
|
# Any uncommitted change to a tracked file means the manifest — which
|
|
# only ever describes committed content — cannot be trusted as a
|
|
# description of "clean Git content". Untracked files are irrelevant
|
|
# here: they can never enter the manifest in the first place.
|
|
dirty = create_manifest.list_dirty_tracked_paths(repo_root)
|
|
if dirty:
|
|
add_error(
|
|
errors,
|
|
"working tree has uncommitted changes to tracked files, so the "
|
|
f"manifest cannot be trusted as clean committed content: {dirty}",
|
|
)
|
|
return
|
|
|
|
try:
|
|
head_commit = create_manifest.resolve_commit("HEAD", repo_root)
|
|
except create_manifest.GitError as exc:
|
|
add_error(errors, f"cannot verify manifest against Git: {exc}")
|
|
return
|
|
|
|
# The itemized comparison below is the actual source of truth for
|
|
# staleness and tampering — it compares every manifest entry directly
|
|
# against live Git blob content, never against a raw `HEAD^{tree}` hash
|
|
# or a commit SHA. That is deliberate, for two independent reasons:
|
|
# (1) PACK_MANIFEST.json is itself a tracked, committed file, so the raw
|
|
# tree hash always includes its own blob — which changes on every
|
|
# regeneration via `generatedAt`. Gating on the raw tree hash is
|
|
# self-referentially impossible to satisfy.
|
|
# (2) a no-conflict merge commit (e.g. a Gitea PR merge) gets a new
|
|
# commit SHA even when it changes no tracked content; gating on
|
|
# commit SHA would spuriously demand a regeneration commit after
|
|
# every such merge (this happened once already: commit 95329ca).
|
|
# Comparing actual (path, checksum) pairs avoids both: it is stable
|
|
# across manifest-only commits and no-op merges, and flags a path if and
|
|
# only if that path's tracked content actually changed.
|
|
self_excluded = manifest.get("selfExcluded", "PACK_MANIFEST.json")
|
|
tracked = set(create_manifest.list_tracked_paths(head_commit, repo_root)) - {self_excluded}
|
|
manifest_paths = set(by_path)
|
|
if missing := sorted(tracked - manifest_paths):
|
|
add_error(errors, f"manifest omits Git-tracked files: {missing}")
|
|
if extra := sorted(manifest_paths - tracked):
|
|
add_error(errors, f"manifest contains files no longer tracked by Git: {extra}")
|
|
|
|
verifiable = sorted(manifest_paths & tracked)
|
|
contents = create_manifest.batch_blob_contents(head_commit, verifiable, repo_root)
|
|
total_bytes = 0
|
|
for rel in verifiable:
|
|
entry = by_path[rel]
|
|
data = contents.get(rel)
|
|
if data is None:
|
|
add_error(errors, f"could not read Git blob content for {rel}")
|
|
continue
|
|
total_bytes += len(data)
|
|
if entry.get("bytes") != len(data):
|
|
add_error(errors, f"manifest byte count mismatch: {rel}")
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
if digest != entry.get("sha256"):
|
|
add_error(errors, f"manifest checksum mismatch: {rel}")
|
|
|
|
if not errors:
|
|
if manifest.get("fileCount") != len(entries):
|
|
add_error(errors, "manifest fileCount does not match files array")
|
|
if manifest.get("totalBytes") != total_bytes:
|
|
add_error(errors, "manifest totalBytes does not match Git blob contents")
|
|
|
|
|
|
def validate_manifest_against_directory(directory: Path, manifest_root: Path = ROOT) -> list[str]:
|
|
"""Validate a plain directory (no Git required) against PACK_MANIFEST.json.
|
|
|
|
Used to prove a clean-room artifact — a `git archive` extraction, a
|
|
fresh clone, or a release-export directory — contains exactly the bytes
|
|
the manifest claims, on any platform, without needing Git present in
|
|
that directory. This works because `git archive` and the manifest's own
|
|
generator both source content from Git blobs, which are never subject
|
|
to checkout-time line-ending conversion.
|
|
"""
|
|
errors: list[str] = []
|
|
manifest_path = manifest_root / "PACK_MANIFEST.json"
|
|
if not manifest_path.is_file():
|
|
return [f"no PACK_MANIFEST.json at {manifest_path}"]
|
|
manifest = load_json(manifest_path)
|
|
if not isinstance(manifest, dict):
|
|
return ["PACK_MANIFEST.json must be a JSON object"]
|
|
by_path = _parse_manifest_entries(manifest, errors)
|
|
self_excluded = manifest.get("selfExcluded", "PACK_MANIFEST.json")
|
|
|
|
# The directory being verified may legitimately contain the manifest
|
|
# file itself — e.g. a `git archive` extraction includes it, since
|
|
# PACK_MANIFEST.json is itself tracked and committed — even though the
|
|
# manifest's own file list never includes it (see selfExcluded). Its
|
|
# presence is expected, not a discrepancy.
|
|
on_disk = {
|
|
candidate.relative_to(directory).as_posix()
|
|
for candidate in directory.rglob("*")
|
|
if candidate.is_file()
|
|
} - {self_excluded}
|
|
manifest_paths = set(by_path)
|
|
if missing := sorted(manifest_paths - on_disk):
|
|
errors.append(f"directory is missing manifest files: {missing}")
|
|
if extra := sorted(on_disk - manifest_paths):
|
|
errors.append(f"directory contains files absent from the manifest: {extra}")
|
|
|
|
for rel in sorted(manifest_paths & on_disk):
|
|
entry = by_path[rel]
|
|
file_path = directory / rel
|
|
data = file_path.read_bytes()
|
|
if entry.get("bytes") != len(data):
|
|
errors.append(f"byte count mismatch outside repository: {rel}")
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
if digest != entry.get("sha256"):
|
|
errors.append(f"checksum mismatch outside repository: {rel}")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Validate the ITWorx MCP Hub design/build pack.")
|
|
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
parser.add_argument(
|
|
"--require-manifest",
|
|
action="store_true",
|
|
help="fail when PACK_MANIFEST.json is absent",
|
|
)
|
|
parser.add_argument(
|
|
"--verify-tree",
|
|
metavar="DIR",
|
|
help=(
|
|
"Skip the full design-pack gate and instead verify that DIR "
|
|
"(e.g. a `git archive` extraction or release-export directory) "
|
|
"contains exactly the files and checksums PACK_MANIFEST.json "
|
|
"records. No Git repository is required inside DIR."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.verify_tree:
|
|
tree_errors = validate_manifest_against_directory(Path(args.verify_tree).resolve())
|
|
if args.json:
|
|
print(json.dumps({"ok": not tree_errors, "errors": tree_errors}, indent=2))
|
|
else:
|
|
status = "PASS" if not tree_errors else "FAIL"
|
|
print(f"ITWorx MCP Hub manifest tree verification: {status}")
|
|
for error in tree_errors:
|
|
print(f"ERROR: {error}")
|
|
return 0 if not tree_errors else 1
|
|
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
validate_required_files(errors)
|
|
plan, _state = validate_plan_and_state(errors, warnings)
|
|
catalog_counts = validate_catalog(errors, warnings)
|
|
schema_count, example_count = validate_schemas_and_examples(errors)
|
|
validate_catalog_json_schemas(errors, warnings)
|
|
validate_compose_blueprint(errors, warnings)
|
|
validate_no_obvious_secrets(errors)
|
|
validate_manifest(errors, warnings)
|
|
if args.require_manifest and not (ROOT / "PACK_MANIFEST.json").is_file():
|
|
add_error(errors, "PACK_MANIFEST.json is required")
|
|
|
|
summary = {
|
|
"ok": not errors,
|
|
"errors": errors,
|
|
"warnings": warnings,
|
|
"packetCount": len(plan.get("packets", [])) if isinstance(plan, dict) else 0,
|
|
"catalogCounts": catalog_counts,
|
|
"schemaCount": schema_count,
|
|
"exampleCount": example_count,
|
|
}
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2))
|
|
else:
|
|
status = "PASS" if not errors else "FAIL"
|
|
print(f"ITWorx MCP Hub design-pack validation: {status}")
|
|
print(
|
|
f"packets={summary['packetCount']} schemas={schema_count} "
|
|
f"examples={example_count} catalog={catalog_counts}"
|
|
)
|
|
for warning in warnings:
|
|
print(f"WARNING: {warning}")
|
|
for error in errors:
|
|
print(f"ERROR: {error}")
|
|
return 0 if not errors else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|