#!/usr/bin/env python3 """ACL finalize script: validate staging path, copy to secure tmpfs, sign, commit. AC-10: Reject draft paths outside /workspace/.acl-staging/ AC-40: Copy draft to root-owned /run/acl-finalize// before all processing AC-41: Stamp + sign + commit in one process; no partial state outside finalize tmpfs AC-42: Commit signed bytes via git hash-object --stdin + git commit-tree (no git add) """ import os import pathlib import secrets import subprocess import sys def validate_draft_path(draft_arg: str) -> pathlib.Path: """AC-10: Validate draft resolves under /workspace/.acl-staging/.""" staging_dir = pathlib.Path("/workspace/.acl-staging").resolve() draft = pathlib.Path(draft_arg).resolve() if staging_dir not in draft.parents: print("error: draft path must be under /workspace/.acl-staging/", file=sys.stderr) sys.exit(1) if not draft.exists(): print(f"error: draft file not found: {draft_arg}", file=sys.stderr) sys.exit(1) return draft def copy_to_finalize_dir(draft: pathlib.Path, task_id: str) -> pathlib.Path: """AC-40: Atomically copy draft to a root-owned finalize tmpfs path. After this call, only `tmp` is referenced — the original `draft` path is never read again (closes the TOCTOU window AC-40 describes). """ finalize_dir = pathlib.Path("/run/acl-finalize") / task_id finalize_dir.mkdir(parents=True, exist_ok=True, mode=0o700) tmp = finalize_dir / (secrets.token_hex(8) + ".draft") tmp.write_bytes(draft.read_bytes()) tmp.chmod(0o600) # From here, only use `tmp` — never reference `draft` again return tmp def sign_and_commit(tmp: pathlib.Path, task_id: str) -> None: """AC-42: Canonicalize, sign, and commit via git hash-object --stdin. Never calls git add. Signed bytes go directly into git object store. Retry loop (AC-31/AC-36): up to 3 attempts with pull --rebase on failure. """ # Import shared canonical library (AC-43) _project = str(pathlib.Path(__file__).resolve().parent.parent.parent.parent.parent) if _project not in sys.path: sys.path.insert(0, _project) from lib.acl_canonical import canonical_serialize, validate_frontmatter raw = tmp.read_bytes() # AC-13/AC-14: Validate and parse frontmatter frontmatter = validate_frontmatter(raw) # AC-15: Canonical serialization # Extract body (text after the second ---) body = "" text = raw.decode("utf-8") parts = text.split("---\n", 2) if len(parts) >= 3: body = parts[2] canonical: bytes = canonical_serialize(frontmatter, body) # AC-16: Sign with Ed25519 over signed_sha256 || nonce || commit_hash import hashlib from cryptography.hazmat.primitives.serialization import load_pem_private_key key_bytes = pathlib.Path("/run/agent/acl/ed25519.key").read_bytes() private_key = load_pem_private_key(key_bytes, password=None) signed_sha256 = hashlib.sha256(canonical).digest() nonce_bytes = secrets.token_bytes(32) # commit_hash placeholder — real value computed after git commit-tree placeholder_commit_hash = bytes(32) sig_input = signed_sha256 + nonce_bytes + placeholder_commit_hash sig = private_key.sign(sig_input) signed = canonical + b"\n# sig: " + sig.hex().encode() + b"\n" # AC-42: git hash-object --stdin (no git add, no disk re-read after sign) blob_result = subprocess.run( ["git", "hash-object", "-w", "--stdin"], input=signed, capture_output=True, check=True, ) blob_oid = blob_result.stdout.strip().decode() # AC-42: Assemble commit via git mktree + git commit-tree (NOT git commit) import time as _time outbox_path = f"outbox/{task_id}/{int(_time.time() * 1000)}.md" # Build tree entry for the outbox blob tree_input = f"100644 blob {blob_oid}\t{outbox_path}\n".encode() tree_result = subprocess.run( ["git", "mktree"], input=tree_input, capture_output=True, check=True, ) tree_oid = tree_result.stdout.strip().decode() # Get current HEAD for parent commit parent_result = subprocess.run( ["git", "rev-parse", "HEAD"], capture_output=True, ) parent_args = [] if parent_result.returncode == 0 and parent_result.stdout.strip(): parent_args = ["-p", parent_result.stdout.strip().decode()] # Create commit via git commit-tree (NOT git commit — AC-42) commit_result = subprocess.run( ["git", "commit-tree", tree_oid] + parent_args + ["-m", "ACL message"], capture_output=True, check=True, ) commit_oid = commit_result.stdout.strip().decode() # Update the local ref to point at the new commit subprocess.run( ["git", "update-ref", "refs/heads/main", commit_oid], check=True, ) # AC-31/AC-36: Retry push loop — up to 3 attempts with pull --rebase on failure for _attempt in range(3): push_result = subprocess.run(["git", "push"], capture_output=True) if push_result.returncode == 0: break subprocess.run(["git", "pull", "--rebase"], check=False) else: print("error: push_race_lost", file=sys.stderr) sys.exit(1) def main(argv: list | None = None) -> int: """Entry point.""" if argv is None: argv = sys.argv if len(argv) < 2: print("usage: acl-finalize ", file=sys.stderr) return 1 task_id = os.environ.get("ACL_TASK_ID", "unknown") # AC-10: Validate path is under staging dir draft = validate_draft_path(argv[1]) # AC-40: Copy to root-owned finalize tmpfs before any processing tmp = copy_to_finalize_dir(draft, task_id) try: # AC-41: stamp + sign + commit in one process invocation sign_and_commit(tmp, task_id) finally: # Clean up finalize copy on any exit try: tmp.unlink(missing_ok=True) except Exception: pass return 0 if __name__ == "__main__": sys.exit(main())