From 1efee969c8ee139b024ef297ca352206951fb9c5 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:49:24 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Add=20.sourceos/manifest.json=20=E2=80=94?= =?UTF-8?q?=20declare=20agentplane=20as=20SourceOS=20agent-execution=20com?= =?UTF-8?q?ponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers agentplane in the SourceOS component registry with: - domain: agent-execution - ownedSchemas: Bundle, GovernanceContext, ValidationArtifact, PlacementDecision, RunArtifact, ReplayArtifact, StopGateArtifact, SourceOSContextToolProviderEvidence, SourceOSContextCuminRun - policyClass: critical (stop-gate logic + live Tekton mutation surface) - dangerousSurfaces: live_tekton_mutation, stop_gate.override --- .sourceos/manifest.json | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .sourceos/manifest.json diff --git a/.sourceos/manifest.json b/.sourceos/manifest.json new file mode 100644 index 0000000..67b7aa3 --- /dev/null +++ b/.sourceos/manifest.json @@ -0,0 +1,36 @@ +{ + "repo": "SocioProphet/agentplane", + "domain": "agent-execution", + "specVersion": "0.1.0", + "ownedSchemas": [ + "Bundle", + "GovernanceContext", + "ValidationArtifact", + "PlacementDecision", + "RunArtifact", + "ReplayArtifact", + "StopGateArtifact", + "SourceOSContextToolProviderEvidence", + "SourceOSContextCuminRun" + ], + "syncEngines": [], + "sourceChannels": [], + "policyClasses": [ + "critical" + ], + "auditEvents": [ + "bundle.validated", + "bundle.placed", + "bundle.executed", + "sourceos.delegated.recorded", + "stop-gate.evaluated" + ], + "dangerousSurfaces": [ + "sourceos.delegated.live_tekton_mutation", + "bundle.stop_gate.override" + ], + "authorityRepos": [ + "SocioProphet/agentplane" + ], + "notes": "Execution control plane for the SocioProphet AI+HW+State stack. Bridges agentplane bundles to SourceOS image production surfaces (Tekton, Katello). Delegated execution records are the evidence boundary between agent intent and SourceOS content lifecycle." +} From 8578cd89b0cf00cc6a24836547e4688e72f92789 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:20:45 -0400 Subject: [PATCH 2/3] Update manifest: add SourceOSInteractionEvidenceBinding + new schemas from main --- .sourceos/manifest.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.sourceos/manifest.json b/.sourceos/manifest.json index 67b7aa3..fd91382 100644 --- a/.sourceos/manifest.json +++ b/.sourceos/manifest.json @@ -11,7 +11,13 @@ "ReplayArtifact", "StopGateArtifact", "SourceOSContextToolProviderEvidence", - "SourceOSContextCuminRun" + "SourceOSContextCuminRun", + "SourceOSInteractionEvidenceBinding", + "AgentCycleHealth", + "AuthorityDependencyEvidence", + "RuntimeSandboxRun", + "WorkspaceProphetControlReceipt", + "ReasoningFailureTrace" ], "syncEngines": [], "sourceChannels": [], From 88e7095552e58bc8aab27e1c8ba4ca76ab4359a9 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:08:17 -0400 Subject: [PATCH 3/3] feat: enforce sourceos image-production bundle blocking conditions --- .../sourceos-image-production-smoke/smoke.sh | 12 +- tests/test_validate_sourceos_bundle.py | 246 ++++++++++++++++++ tools/sp_run.py | 11 + tools/validate_sourceos_bundle.py | 150 +++++++++++ 4 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 tests/test_validate_sourceos_bundle.py create mode 100644 tools/validate_sourceos_bundle.py diff --git a/bundles/sourceos-image-production-smoke/smoke.sh b/bundles/sourceos-image-production-smoke/smoke.sh index 5255fec..971ac16 100644 --- a/bundles/sourceos-image-production-smoke/smoke.sh +++ b/bundles/sourceos-image-production-smoke/smoke.sh @@ -3,8 +3,14 @@ set -euo pipefail echo "[sourceos-image-production-smoke] validating SourceOS image-production bundle wiring" -test -n "${AGENTPLANE_BUNDLE_PATH:-bundles/sourceos-image-production-smoke/bundle.json}" -test -f "${AGENTPLANE_BUNDLE_PATH:-bundles/sourceos-image-production-smoke/bundle.json}" +BUNDLE_PATH="${AGENTPLANE_BUNDLE_PATH:-bundles/sourceos-image-production-smoke/bundle.json}" + +test -n "${BUNDLE_PATH}" +test -f "${BUNDLE_PATH}" + +echo "[sourceos-image-production-smoke] bundle path: ${BUNDLE_PATH}" + +AGENTPLANE_ROOT="${AGENTPLANE_ROOT:-$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo ".")}" +python3 "${AGENTPLANE_ROOT}/tools/validate_sourceos_bundle.py" --bundle "${BUNDLE_PATH}" -echo "[sourceos-image-production-smoke] bundle path: ${AGENTPLANE_BUNDLE_PATH:-bundles/sourceos-image-production-smoke/bundle.json}" echo "[sourceos-image-production-smoke] smoke complete" diff --git a/tests/test_validate_sourceos_bundle.py b/tests/test_validate_sourceos_bundle.py new file mode 100644 index 0000000..3ca9dcd --- /dev/null +++ b/tests/test_validate_sourceos_bundle.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +from validate_sourceos_bundle import validate_bundle # noqa: E402 + + +def _valid_bundle() -> dict: + return { + "apiVersion": "agentplane.socioprophet.org/v0.1", + "kind": "Bundle", + "metadata": { + "name": "sourceos-image-production-test", + "version": "0.1.0", + "createdAt": "2026-06-16T00:00:00Z", + "licensePolicy": {"allowAGPL": False}, + "source": {"git": {"rev": "abc1234def5678"}}, + }, + "spec": { + "policy": { + "lane": "staging", + "humanGateRequired": True, + "maxRunSeconds": 120, + "policyPackRef": "policy-packs/sourceos/image-production-staging", + "policyPackHash": "deadbeef", + "failOnTimeout": True, + }, + "sourceos": { + "artifactTruthRef": "SociOS-Linux/SourceOS:docs/ARTIFACT_TRUTH.md", + "flavorRef": "SociOS-Linux/SourceOS:flavors/sourceos-workstation.example.yaml", + }, + "secrets": { + "required": ["KATELLO_CLI_USERNAME_FILE", "KATELLO_CLI_PASSWORD_FILE"], + "secretRefRoot": "secrets://sourceos/katello", + }, + "outputs": { + "evidenceBundleRef": "urn:srcos:evidence-bundle:test", + }, + }, + } + + +def _check(bundle: dict, bundle_dir: Path | None = None) -> dict: + return validate_bundle(bundle, bundle_dir or Path(".")) + + +def _blocks(result: dict) -> list[str]: + return [f["condition"] for f in result["findings"] if f["severity"] == "block"] + + +def _warns(result: dict) -> list[str]: + return [f["condition"] for f in result["findings"] if f["severity"] == "warn"] + + +# ── happy path ───────────────────────────────────────────────────────────── + + +def test_valid_bundle_passes() -> None: + result = _check(_valid_bundle()) + assert result["ok"] is True + assert _blocks(result) == [] + + +# ── license ──────────────────────────────────────────────────────────────── + + +def test_allow_agpl_true_blocks() -> None: + b = _valid_bundle() + b["metadata"]["licensePolicy"]["allowAGPL"] = True + result = _check(b) + assert "license_policy.allowAGPL" in _blocks(result) + + +def test_allow_agpl_missing_blocks() -> None: + b = _valid_bundle() + del b["metadata"]["licensePolicy"]["allowAGPL"] + result = _check(b) + assert "license_policy.allowAGPL" in _blocks(result) + + +# ── git rev ──────────────────────────────────────────────────────────────── + + +def test_rev_unset_is_warn_not_block() -> None: + b = _valid_bundle() + b["metadata"]["source"]["git"]["rev"] = "UNSET" + result = _check(b) + assert result["ok"] is True + assert "metadata.source.git.rev" in _warns(result) + assert "metadata.source.git.rev" not in _blocks(result) + + +def test_rev_missing_blocks() -> None: + b = _valid_bundle() + del b["metadata"]["source"]["git"]["rev"] + result = _check(b) + assert "metadata.source.git.rev" in _blocks(result) + + +# ── sourceos ─────────────────────────────────────────────────────────────── + + +def test_missing_artifact_truth_ref_blocks() -> None: + b = _valid_bundle() + del b["spec"]["sourceos"]["artifactTruthRef"] + result = _check(b) + assert "spec.sourceos.artifactTruthRef" in _blocks(result) + + +def test_missing_sourceos_block_blocks() -> None: + b = _valid_bundle() + del b["spec"]["sourceos"] + result = _check(b) + assert "spec.sourceos.artifactTruthRef" in _blocks(result) + + +# ── policy ───────────────────────────────────────────────────────────────── + + +def test_invalid_lane_blocks() -> None: + b = _valid_bundle() + b["spec"]["policy"]["lane"] = "development" + result = _check(b) + assert "spec.policy.lane" in _blocks(result) + + +def test_prod_lane_is_valid() -> None: + b = _valid_bundle() + b["spec"]["policy"]["lane"] = "prod" + result = _check(b) + assert "spec.policy.lane" not in _blocks(result) + + +def test_policy_pack_ref_unset_is_warn() -> None: + b = _valid_bundle() + b["spec"]["policy"]["policyPackRef"] = "UNSET" + result = _check(b) + assert result["ok"] is True + assert "spec.policy.policyPackRef" in _warns(result) + + +def test_human_gate_missing_blocks() -> None: + b = _valid_bundle() + del b["spec"]["policy"]["humanGateRequired"] + result = _check(b) + assert "spec.policy.humanGateRequired" in _blocks(result) + + +# ── secrets ──────────────────────────────────────────────────────────────── + + +def test_missing_secrets_required_blocks() -> None: + b = _valid_bundle() + del b["spec"]["secrets"]["required"] + result = _check(b) + assert "spec.secrets.required" in _blocks(result) + + +def test_empty_secrets_required_blocks() -> None: + b = _valid_bundle() + b["spec"]["secrets"]["required"] = [] + result = _check(b) + assert "spec.secrets.required" in _blocks(result) + + +def test_inline_secret_value_blocks() -> None: + b = _valid_bundle() + b["spec"]["secrets"]["value"] = "supersecret" + result = _check(b) + assert "spec.secrets.inline" in _blocks(result) + + +# ── sociosAutomation ─────────────────────────────────────────────────────── + + +def test_socios_automation_missing_tekton_ref_blocks() -> None: + b = _valid_bundle() + b["spec"]["sociosAutomation"] = { + "katelloProduct": "SourceOS", + "katelloRepository": "sourceos-live-iso", + "katelloLifecycleEnvironment": "qa", + } + result = _check(b) + assert "spec.sociosAutomation.tektonPipelineRef" in _blocks(result) + + +def test_socios_automation_fully_populated_passes() -> None: + b = _valid_bundle() + b["spec"]["sociosAutomation"] = { + "tektonPipelineRef": "SociOS-Linux/socios:pipelines/tekton/pipeline-customize-live-iso.yaml", + "katelloProduct": "SourceOS", + "katelloRepository": "sourceos-live-iso", + "katelloLifecycleEnvironment": "qa", + } + result = _check(b) + assert all("sociosAutomation" not in c for c in _blocks(result)) + + +# ── outputs ──────────────────────────────────────────────────────────────── + + +def test_no_outputs_blocks() -> None: + b = _valid_bundle() + del b["spec"]["outputs"] + result = _check(b) + assert "spec.outputs" in _blocks(result) + + +def test_all_outputs_unset_blocks() -> None: + b = _valid_bundle() + b["spec"]["outputs"] = { + "releaseSetRef": "UNSET", + "bootReleaseSetRef": "UNSET", + } + result = _check(b) + assert "spec.outputs" in _blocks(result) + + +def test_one_real_output_ref_passes() -> None: + b = _valid_bundle() + b["spec"]["outputs"] = {"katelloContentRef": "katello://SourceOS/SourceOS Recovery/sourceos-live.iso@sha256:abc"} + result = _check(b) + assert "spec.outputs" not in _blocks(result) + + +# ── smoke script ─────────────────────────────────────────────────────────── + + +def test_smoke_script_exists_passes(tmp_path: Path) -> None: + script = tmp_path / "smoke.sh" + script.write_text("#!/bin/bash\n") + b = _valid_bundle() + b["spec"]["smoke"] = {"script": "smoke.sh"} + result = validate_bundle(b, tmp_path) + assert "spec.smoke.script" not in _blocks(result) + + +def test_smoke_script_missing_blocks(tmp_path: Path) -> None: + b = _valid_bundle() + b["spec"]["smoke"] = {"script": "nonexistent.sh"} + result = validate_bundle(b, tmp_path) + assert "spec.smoke.script" in _blocks(result) diff --git a/tools/sp_run.py b/tools/sp_run.py index 7038721..6a23589 100644 --- a/tools/sp_run.py +++ b/tools/sp_run.py @@ -170,6 +170,13 @@ def command_dossier(args: argparse.Namespace) -> int: return 0 +def command_validate_bundle(args: argparse.Namespace) -> int: + import subprocess + tool = Path(__file__).parent / "validate_sourceos_bundle.py" + result = subprocess.run([sys.executable, str(tool), "--bundle", args.bundle]) + return result.returncode + + def command_validate_dossier(args: argparse.Namespace) -> int: try: validate_run_dossier.validate_schema(validate_run_dossier.load_json(validate_run_dossier.SCHEMA)) @@ -478,6 +485,10 @@ def build_parser() -> argparse.ArgumentParser: validate.add_argument("dossier_json") validate.set_defaults(func=command_validate_dossier) + validate_bundle = subparsers.add_parser("validate-bundle", help="Validate a SourceOS image-production bundle against blocking conditions.") + validate_bundle.add_argument("--bundle", required=True, help="Path to bundle.json") + validate_bundle.set_defaults(func=command_validate_bundle) + return parser diff --git a/tools/validate_sourceos_bundle.py b/tools/validate_sourceos_bundle.py new file mode 100644 index 0000000..7a03893 --- /dev/null +++ b/tools/validate_sourceos_bundle.py @@ -0,0 +1,150 @@ +"""Validate a SourceOS image-production bundle against blocking conditions. + +Usage: + python3 validate_sourceos_bundle.py --bundle + +Exit codes: + 0 — ok (no block-severity findings) + 2 — one or more blocking findings + +Output: JSON to stdout: + {"ok": bool, "findings": [{"condition": str, "severity": "block"|"warn", "message": str}]} +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +def _get(d: dict, *keys: str) -> Any: + """Safely traverse nested dicts; returns None if any key is missing.""" + for k in keys: + if not isinstance(d, dict): + return None + d = d.get(k) # type: ignore[assignment] + return d + + +def validate_bundle(bundle: dict[str, Any], bundle_dir: Path) -> dict[str, Any]: + """Run all blocking-condition checks and return a findings dict.""" + findings: list[dict[str, str]] = [] + + def block(condition: str, message: str) -> None: + findings.append({"condition": condition, "severity": "block", "message": message}) + + def warn(condition: str, message: str) -> None: + findings.append({"condition": condition, "severity": "warn", "message": message}) + + # 1. AGPL must be false + allow_agpl = _get(bundle, "metadata", "licensePolicy", "allowAGPL") + if allow_agpl is not False: + block("license_policy.allowAGPL", f"metadata.licensePolicy.allowAGPL must be false; got {allow_agpl!r}") + + # 2. git rev must not be UNSET + rev = _get(bundle, "metadata", "source", "git", "rev") + if not rev: + block("metadata.source.git.rev", "metadata.source.git.rev is missing") + elif rev == "UNSET": + warn("metadata.source.git.rev", "metadata.source.git.rev is UNSET — must be set before production use") + + # 3. artifactTruthRef required + truth_ref = _get(bundle, "spec", "sourceos", "artifactTruthRef") + if not truth_ref: + block("spec.sourceos.artifactTruthRef", "spec.sourceos.artifactTruthRef is required and must be non-empty") + + # 4. humanGateRequired must be present + human_gate = _get(bundle, "spec", "policy", "humanGateRequired") + if human_gate is None: + block("spec.policy.humanGateRequired", "spec.policy.humanGateRequired is required") + + # 5. policy lane must be staging or prod + lane = _get(bundle, "spec", "policy", "lane") + if lane not in ("staging", "prod"): + block("spec.policy.lane", f"spec.policy.lane must be 'staging' or 'prod'; got {lane!r}") + + # 6. policyPackRef must be non-empty (warn if UNSET, block if missing) + pack_ref = _get(bundle, "spec", "policy", "policyPackRef") + if not pack_ref: + block("spec.policy.policyPackRef", "spec.policy.policyPackRef is required") + elif pack_ref == "UNSET": + warn("spec.policy.policyPackRef", "spec.policy.policyPackRef is UNSET — must be set before production use") + + # 7. secrets.required must be a non-empty list + secrets_required = _get(bundle, "spec", "secrets", "required") + if not isinstance(secrets_required, list) or len(secrets_required) == 0: + block("spec.secrets.required", "spec.secrets.required must be a non-empty list of secret references") + + # 8. No inline secrets (no 'value' or 'inlineValue' keys anywhere in spec.secrets) + def _has_inline(obj: Any) -> bool: + if isinstance(obj, dict): + if "value" in obj or "inlineValue" in obj: + return True + return any(_has_inline(v) for v in obj.values()) + if isinstance(obj, list): + return any(_has_inline(item) for item in obj) + return False + + secrets_block = _get(bundle, "spec", "secrets") or {} + if _has_inline(secrets_block): + block("spec.secrets.inline", "spec.secrets must not contain inline secret values (no 'value' or 'inlineValue' keys)") + + # 9. sociosAutomation: if present, required sub-fields must be non-empty + socios = _get(bundle, "spec", "sociosAutomation") + if socios is not None: + for field_name in ("tektonPipelineRef", "katelloProduct", "katelloRepository", "katelloLifecycleEnvironment"): + val = socios.get(field_name) if isinstance(socios, dict) else None + if not val: + block( + f"spec.sociosAutomation.{field_name}", + f"spec.sociosAutomation.{field_name} is required when spec.sociosAutomation is present", + ) + + # 10. At least one output ref must be present and not UNSET + outputs = _get(bundle, "spec", "outputs") or {} + output_keys = ("releaseSetRef", "bootReleaseSetRef", "katelloContentRef", "evidenceBundleRef") + present_outputs = [k for k in output_keys if outputs.get(k) and outputs[k] != "UNSET"] + if not present_outputs: + block("spec.outputs", "at least one output ref must be present and not 'UNSET' in spec.outputs") + + # 11. smoke script must exist if declared. + # Paths are tried in order: relative to bundle_dir, then relative to CWD + # (repo root). Bundle specs typically use repo-root-relative paths. + smoke_script = _get(bundle, "spec", "smoke", "script") + if smoke_script: + candidates = [(bundle_dir / smoke_script).resolve(), (Path.cwd() / smoke_script).resolve()] + if not any(p.exists() for p in candidates): + block("spec.smoke.script", f"spec.smoke.script '{smoke_script}' not found (checked bundle dir and repo root)") + + ok = not any(f["severity"] == "block" for f in findings) + return {"ok": ok, "findings": findings} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bundle", required=True, help="Path to bundle.json") + args = parser.parse_args(argv) + + bundle_path = Path(args.bundle).resolve() + if not bundle_path.exists(): + result = {"ok": False, "findings": [{"condition": "bundle_file", "severity": "block", "message": f"bundle file not found: {bundle_path}"}]} + print(json.dumps(result, indent=2)) + return 2 + + try: + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + result = {"ok": False, "findings": [{"condition": "bundle_parse", "severity": "block", "message": f"failed to parse bundle: {exc}"}]} + print(json.dumps(result, indent=2)) + return 2 + + result = validate_bundle(bundle, bundle_path.parent) + print(json.dumps(result, indent=2)) + return 0 if result["ok"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main())