#!/usr/bin/env bash # validate-depo.sh — validate a package against manifest-spec v1.0 + agent-card-spec v1.0. # Rejects: malformed manifest, missing layers/files, unregistered tools, missing # verification, secrets, missing signature/hash. # # Usage: # validate-depo.sh [--key ] [--registry ] # Writes /VALIDATE on success (the spec-mandated gate file). set -euo pipefail if [ $# -lt 1 ]; then echo "usage: validate-depo.sh [--key ] [--registry ]" >&2 exit 1 fi PKG_DIR="$1" shift PUBKEY="" REGISTRY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/tool-registry.json" while [ $# -gt 0 ]; do case "$1" in --key) PUBKEY="${2:?}"; shift 2 ;; --registry) REGISTRY="${2:?}"; shift 2 ;; *) echo "validate-depo: unknown arg $1" >&2; exit 1 ;; esac done MANIFEST="$PKG_DIR/manifest.json" [ -f "$MANIFEST" ] || { echo "validate-depo: no manifest.json in $PKG_DIR" >&2; exit 1; } # Signature + hash gate (refusal per spec: No signature = refuse) for art in "$MANIFEST.sig" "$MANIFEST.sha256"; do [ -f "$art" ] || { echo "validate-depo: FAIL — missing $art (refusal per spec)" >&2; exit 1; } done GOT="$(sha256sum "$MANIFEST" | cut -d' ' -f1)" WANT="$(cat "$MANIFEST.sha256")" [ "$GOT" = "$WANT" ] || { echo "validate-depo: FAIL — manifest hash mismatch (modified after signing)" >&2; exit 1; } if [ -n "$PUBKEY" ]; then python3 - "$PUBKEY" "$MANIFEST.sig" "$MANIFEST" <<'PY' || { echo "validate-depo: FAIL — bad signature" >&2; exit 1; } import base64, sys from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey pub = Ed25519PublicKey.from_public_bytes(open(sys.argv[1], "rb").read()) sig = base64.b64decode(open(sys.argv[2], "rb").read()) data = open(sys.argv[3], "rb").read() pub.verify(sig, data) PY echo "validate-depo: signature OK" fi python3 - "$MANIFEST" "$PKG_DIR" "$REGISTRY" <<'PY' || { echo "validate-depo: FAIL — semantic checks" >&2; exit 1; } import json, os, re, sys manifest_path, root, registry_path = sys.argv[1], os.path.abspath(sys.argv[2]), sys.argv[3] m = json.load(open(manifest_path)) errs = [] def req(obj, key, where): if key not in obj or obj[key] in (None, "", [], {}): errs.append(f"{where}: missing/invalid '{key}'") req(m, "schema_version", "manifest") if m.get("schema_version") != "1.0": errs.append("manifest: schema_version must be '1.0'") req(m, "package_id", "manifest") req(m, "display_name", "manifest") req(m, "version", "manifest") req(m, "license", "manifest") req(m, "requires", "manifest") req(m, "layers", "manifest") req(m, "install", "manifest") if not m.get("install", {}).get("order"): errs.append("manifest.install: missing/invalid 'order'") if "agents" not in m.get("layers", {}): errs.append("manifest.layers: no agents layer (spec requires it)") for name in ("odoo_modules", "agents", "docs", "config"): if name not in m.get("layers", {}): errs.append(f"manifest.layers: missing '{name}'") # Every layer file must exist on disk. # odoo_modules entries are module names -> modules//__manifest__.py # agents entries are card ids -> agents/.json # docs/config/brain entries are plain paths relative to the package root for layer, files in m.get("layers", {}).items(): for f in (files if isinstance(files, list) else [files]): if layer == "odoo_modules": p = os.path.join(root, "modules", f, "__manifest__.py") label = f"modules/{f}/__manifest__.py" elif layer == "agents": p = os.path.join(root, "agents", f"{f}.json") label = f"agents/{f}.json" else: p = os.path.join(root, f) label = f if not os.path.exists(p): errs.append(f"manifest.layers.{layer}: file missing on disk: {label}") # Agent cards must satisfy agent-card-spec v1.0 registry = {} if os.path.exists(registry_path): registry = json.load(open(registry_path)).get("tools", {}) agents_dir = os.path.join(root, "agents") for aid in m.get("layers", {}).get("agents", []): card = os.path.join(agents_dir, f"{aid}.json") if not os.path.exists(card): errs.append(f"agent card missing: {aid}.json") continue try: c = json.load(open(card)) except json.JSONDecodeError as e: errs.append(f"agent card {aid}: invalid JSON ({e})") continue req(c, "schema_version", f"agent {aid}") req(c, "agent_id", f"agent {aid}") if c.get("agent_id") != aid: errs.append(f"agent {aid}: agent_id mismatch ({c.get('agent_id')})") req(c, "name", f"agent {aid}") req(c, "role", f"agent {aid}") req(c, "description", f"agent {aid}") req(c, "tools", f"agent {aid}") req(c, "gates", f"agent {aid}") req(c, "verification", f"agent {aid}") req(c, "memory", f"agent {aid}") if "definition_of_done" not in c.get("verification", {}) and "check" not in c.get("verification", {}): errs.append(f"agent {aid}: verification must contain definition_of_done or check") for t in c.get("tools", []): if registry and t not in registry: errs.append(f"agent {aid}: unregistered tool '{t}' (not in tool-registry.json)") if t == "script.run": errs.append(f"agent {aid}: script.run tool requires explicit allowlist — use the file.* + odoo.* primitives") for g in c.get("gates", []): if g not in ("hours", "irreversible-action", "none"): errs.append(f"agent {aid}: unknown gate '{g}'") # No secrets (lenient scan — real scan hooks into the target's secrets_scan) BANNED = re.compile(r"(api[_-]?key|secret|password|token)\s*[:=]\s*['\"][A-Za-z0-9_\-]{8,}['\"]", re.I) for dirpath, _, files in os.walk(root): for fn in files: if fn.endswith((".sig", ".sha256")): continue try: text = open(os.path.join(dirpath, fn), "rb").read().decode("utf-8", "ignore") except Exception: continue for mt in BANNED.finditer(text): errs.append(f"possible secret in {os.path.relpath(os.path.join(dirpath, fn), root)}: {mt.group(0)[:40]}") if errs: for e in errs: print(" -", e, file=sys.stderr) sys.exit(1) print("manifest + agent cards + files + tools + secrets scan: PASS") PY # Write the spec-mandated VALIDATE gate file cat > "$PKG_DIR/VALIDATE" <