178 lines
6.0 KiB
Bash
Executable File
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())
|