184 lines
7.5 KiB
Python
184 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Apply an approved Goal patch with bounded paths and rollback-on-failure."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import importlib.util
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
def root() -> str:
|
|
current = os.path.abspath(os.path.dirname(__file__))
|
|
while current != os.path.dirname(current):
|
|
if os.path.isdir(os.path.join(current, ".specify")):
|
|
return current
|
|
current = os.path.dirname(current)
|
|
raise SystemExit("GOAL_APPLY_ROOT_NOT_FOUND")
|
|
|
|
|
|
ROOT = root()
|
|
INBOX = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash", "approval-inbox.py")
|
|
_MANIFEST_SPEC = importlib.util.spec_from_file_location(
|
|
"casan_project_manifest",
|
|
os.path.join(os.path.dirname(__file__), "project_manifest.py"),
|
|
)
|
|
PROJECT_MANIFEST = importlib.util.module_from_spec(_MANIFEST_SPEC)
|
|
_MANIFEST_SPEC.loader.exec_module(PROJECT_MANIFEST)
|
|
|
|
|
|
def now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def load(path: str) -> dict:
|
|
with open(path, encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def save(path: str, payload: dict) -> None:
|
|
directory = os.path.dirname(path)
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=directory, delete=False) as handle:
|
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
handle.write("\n")
|
|
temporary = handle.name
|
|
os.chmod(temporary, 0o600)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def run(command: list[str], timeout: int = 180) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
def verify_approval(job: dict) -> dict:
|
|
result = run(["python3", INBOX, "list", "--status", "all"], 30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError("GOAL_APPLY_APPROVAL_STORE_UNAVAILABLE")
|
|
proposal = next((row for row in json.loads(result.stdout).get("proposals", []) if row.get("id") == job.get("approval", {}).get("id")), None)
|
|
artifact = job.get("patch_artifact", {})
|
|
if not proposal or proposal.get("status") != "approved":
|
|
raise PermissionError("GOAL_APPLY_APPROVAL_REQUIRED")
|
|
if proposal.get("action") != "goal.workspace.execute" or proposal.get("payload", {}).get("goal_id") != job.get("id"):
|
|
raise PermissionError("GOAL_APPLY_APPROVAL_SCOPE_MISMATCH")
|
|
if proposal.get("payload", {}).get("patch_sha256") != artifact.get("sha256"):
|
|
raise PermissionError("GOAL_APPLY_PATCH_HASH_MISMATCH")
|
|
if sorted(proposal.get("payload", {}).get("changed_files", [])) != sorted(artifact.get("files", [])):
|
|
raise PermissionError("GOAL_APPLY_FILE_SCOPE_MISMATCH")
|
|
return proposal
|
|
|
|
|
|
def _manifest_for_files(files: list[str]) -> dict:
|
|
selected = os.environ.get("CASAN_PROJECT_MANIFEST")
|
|
project = os.environ.get("CASAN_PROJECT_ID")
|
|
if selected or project:
|
|
return PROJECT_MANIFEST.load(ROOT, selected, project)
|
|
|
|
registry = load(os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json"))
|
|
candidates = []
|
|
for entry in registry.get("projects", []):
|
|
manifest_path = entry.get("manifest")
|
|
if not manifest_path or entry.get("status") != "active":
|
|
continue
|
|
try:
|
|
manifest = PROJECT_MANIFEST.load(ROOT, manifest_path)
|
|
except (OSError, ValueError):
|
|
continue
|
|
if any(any(path.startswith(root.rstrip("/") + "/") for root in manifest["source_roots"]) for path in files):
|
|
candidates.append(manifest)
|
|
if len(candidates) != 1:
|
|
reason = "none" if not candidates else "multiple"
|
|
raise RuntimeError(f"GOAL_APPLY_PROJECT_MANIFEST_{reason.upper()}")
|
|
return candidates[0]
|
|
|
|
|
|
def verification_commands(files: list[str], manifest: dict | None = None) -> list[list[str]]:
|
|
project = manifest or _manifest_for_files(files)
|
|
# `git apply --check --whitespace=error` already validates the exact patch
|
|
# before mutation. Runtime images intentionally do not need repository
|
|
# metadata, so post-apply verification is limited to manifest build/tests.
|
|
return PROJECT_MANIFEST.verification_commands(project, files)
|
|
|
|
|
|
def ready_for_apply(job: dict) -> bool:
|
|
if not job.get("patch_artifact"):
|
|
return False
|
|
if job.get("status") == "requires_approval":
|
|
return True
|
|
return (
|
|
job.get("status") == "failed"
|
|
and job.get("error") == "GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK"
|
|
)
|
|
|
|
|
|
def execute(job_path: str, actor: str) -> dict:
|
|
job = load(job_path)
|
|
if not ready_for_apply(job):
|
|
raise RuntimeError("GOAL_APPLY_JOB_NOT_READY")
|
|
proposal = verify_approval(job)
|
|
job.setdefault("approval", {})["status"] = "approved"
|
|
if proposal.get("approver") != actor:
|
|
raise PermissionError("GOAL_APPLY_APPROVER_IDENTITY_MISMATCH")
|
|
artifact = job["patch_artifact"]
|
|
patch_path = os.path.realpath(os.path.join(ROOT, artifact["path"]))
|
|
state_root = os.path.realpath(os.path.join(ROOT, ".specify", "state", "goals"))
|
|
if not patch_path.startswith(state_root + os.sep) or not os.path.isfile(patch_path):
|
|
raise PermissionError("GOAL_APPLY_ARTIFACT_PATH_DENIED")
|
|
with open(patch_path, "rb") as handle:
|
|
actual_hash = hashlib.sha256(handle.read()).hexdigest()
|
|
if actual_hash != artifact["sha256"]:
|
|
raise PermissionError("GOAL_APPLY_ARTIFACT_TAMPERED")
|
|
|
|
check = run(["git", "apply", "--check", "--whitespace=error", patch_path], 30)
|
|
if check.returncode != 0:
|
|
raise RuntimeError("GOAL_APPLY_PRECONDITION_FAILED:" + (check.stderr or check.stdout).strip()[:200])
|
|
applied = run(["git", "apply", "--whitespace=error", patch_path], 30)
|
|
if applied.returncode != 0:
|
|
raise RuntimeError("GOAL_APPLY_FAILED:" + (applied.stderr or applied.stdout).strip()[:200])
|
|
|
|
checks = []
|
|
try:
|
|
for command in verification_commands(artifact.get("files", [])):
|
|
result = run(command, 300)
|
|
checks.append({"command": " ".join(command), "exit_code": result.returncode, "output": (result.stdout + result.stderr)[-4000:]})
|
|
if result.returncode != 0:
|
|
raise RuntimeError("GOAL_APPLY_VERIFICATION_FAILED")
|
|
except Exception:
|
|
rollback = run(["git", "apply", "--reverse", patch_path], 30)
|
|
if rollback.returncode != 0:
|
|
raise RuntimeError("GOAL_APPLY_ROLLBACK_FAILED")
|
|
artifact["status"] = "awaiting_apply_retry"
|
|
job.update(status="requires_approval", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", patch_artifact=artifact, verification=checks, finished_at=now(), updated_at=now())
|
|
save(job_path, job)
|
|
raise
|
|
|
|
artifact["status"] = "applied"
|
|
artifact["applied_at"] = now()
|
|
artifact["applied_by"] = actor
|
|
job.update(status="completed", result="Approved implementation patch applied and verified successfully.", patch_artifact=artifact, verification=checks, finished_at=now(), updated_at=now())
|
|
save(job_path, job)
|
|
return job
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--job-file", required=True)
|
|
parser.add_argument("--actor", required=True)
|
|
args = parser.parse_args()
|
|
try:
|
|
print(json.dumps(execute(os.path.realpath(args.job_file), args.actor), ensure_ascii=False))
|
|
return 0
|
|
except PermissionError as error:
|
|
print(str(error), file=os.sys.stderr)
|
|
return 3
|
|
except Exception as error:
|
|
print(str(error), file=os.sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|