Files
agent-runtime-framework/harnesses/capabilities/agent-communication/v1/finalize.sh

178 lines
6.0 KiB
Bash
Executable File

#!/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/<task_id>/ 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 <draft>", 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())