212 lines
12 KiB
Python

"""Scoped task-branch grants and durable compare-and-swap ownership ledger."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time
from pathlib import Path
from typing import Any
from gitea_api_policy import PolicyError, _validate_ref, _validate_repo, _validate_sha
MAX_GRANT_BYTES = 4096
MAX_GRANT_SECONDS = 300
MAX_ANCESTRY_STEPS = 1024
KEY_FILE = Path(os.environ.get("HERMES_SCM_TASK_GRANT_KEY_FILE", "/vault/secrets/scm-task-grant"))
LEDGER_PATH = Path(os.environ.get("HERMES_SCM_TASK_LEDGER", "/scm-state/task-branches.db"))
ADOPTIONS_PATH = Path(os.environ.get("HERMES_SCM_TASK_ADOPTIONS", "/scm-adoptions/task-branch-adoptions.json"))
REQUIRED = frozenset({"repo", "ref", "base", "board", "root_task_id", "assignment_task_id", "run", "ordinal", "expires", "expected_old", "new_head", "continuation_kind"})
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
ZERO_SHA = "0" * 40
def _key() -> bytes:
try:
value = KEY_FILE.read_text(encoding="ascii").strip()
except OSError as exc:
raise PolicyError("SCM task grant key is unavailable") from exc
if not re.fullmatch(r"[0-9a-f]{64}", value):
raise PolicyError("SCM task grant key is invalid")
return value.encode("ascii")
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _unb64(value: str) -> bytes:
if not isinstance(value, str) or len(value) > MAX_GRANT_BYTES or not value.isascii():
raise PolicyError("SCM task grant is invalid")
try:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
except ValueError as exc:
raise PolicyError("SCM task grant is invalid") from exc
def sign_grant(claims: dict[str, Any], key: bytes | None = None) -> str:
"""Create a compact signed grant; mediator callers hold the key privately."""
if set(claims) != REQUIRED:
raise PolicyError("SCM task grant claims are invalid")
payload = json.dumps(claims, sort_keys=True, separators=(",", ":")).encode("ascii")
signature = hmac.new(key or _key(), payload, hashlib.sha256).digest()
return _b64(payload) + "." + _b64(signature)
def verify_grant(token: str, *, now: int | None = None, key: bytes | None = None) -> dict[str, Any]:
"""Verify one short-lived grant and normalize only safe repository fields."""
parts = token.split(".") if isinstance(token, str) else []
if len(parts) != 2:
raise PolicyError("SCM task grant is invalid")
payload, signature = _unb64(parts[0]), _unb64(parts[1])
if not hmac.compare_digest(hmac.new(key or _key(), payload, hashlib.sha256).digest(), signature):
raise PolicyError("SCM task grant signature is invalid")
try:
claims = json.loads(payload)
except (TypeError, ValueError) as exc:
raise PolicyError("SCM task grant is invalid") from exc
if not isinstance(claims, dict) or set(claims) != REQUIRED:
raise PolicyError("SCM task grant claims are invalid")
claims["repo"] = _validate_repo(claims["repo"])
claims["ref"] = _validate_ref(claims["ref"], "task branch")
claims["base"] = _validate_ref(claims["base"], "base branch")
claims["expected_old"] = _validate_sha(claims["expected_old"], "expected old SHA")
claims["new_head"] = _validate_sha(claims["new_head"], "new head SHA")
if not isinstance(claims["ordinal"], int) or isinstance(claims["ordinal"], bool):
raise PolicyError("SCM task grant ordinal is invalid")
if claims["continuation_kind"] not in {"", "repair", "review"}:
raise PolicyError("SCM task grant continuation is invalid")
expiry = claims["expires"]
current = int(time.time()) if now is None else now
if not isinstance(expiry, int) or not current < expiry <= current + MAX_GRANT_SECONDS:
raise PolicyError("SCM task grant is expired")
if not all(isinstance(claims[name], str) and IDENTIFIER.fullmatch(claims[name]) for name in ("board", "root_task_id", "assignment_task_id", "run")):
raise PolicyError("SCM task grant binding is invalid")
return claims
class TaskLedger:
"""Broker-owned branch ownership state; no silent claim of existing refs."""
def __init__(self, path: Path = LEDGER_PATH) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
self.path = path
with self._connect() as con:
con.execute("create table if not exists task_branches (repo text not null, ref text not null, board text not null, root_task_id text not null, latest_head text not null, pr_number integer, primary key(repo, ref))")
con.execute("create table if not exists task_roots (repo text not null, board text not null, root_task_id text not null, ref text not null, primary key(repo,board,root_task_id))")
def _connect(self):
return sqlite3.connect(self.path, timeout=5, isolation_level="IMMEDIATE")
@staticmethod
def _owner(claims: dict[str, Any]) -> tuple[str, str]:
return claims["board"], claims["root_task_id"]
def get(self, repo: str, ref: str) -> tuple[str, str, str] | None:
with self._connect() as con:
row = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (repo, ref)).fetchone()
return tuple(row) if row is not None else None
@staticmethod
def _adoption(claims: dict[str, Any], remote_head: str | None) -> bool:
"""Accept only an operator-reviewed exact migration record.
The ConfigMap is mounted read-only and starts empty. A task grant can
consume a matching record but cannot create, edit, or broaden one.
"""
try:
raw = ADOPTIONS_PATH.read_bytes()
value = json.loads(raw)
except (OSError, ValueError, TypeError):
return False
if not isinstance(value, dict) or len(raw) > 64 * 1024:
return False
record = value.get(f"{claims['repo']}/{claims['ref']}")
if not isinstance(record, dict):
return False
return (
record.get("board") == claims["board"]
and record.get("root_task_id") == claims["root_task_id"]
and record.get("latest_head") == remote_head == claims["expected_old"]
)
def seed_adoption(self, record: dict[str, Any], remote_head: str | None) -> None:
"""Import one reviewed legacy branch only when Forgejo still matches it."""
required = {"repo", "ref", "board", "root_task_id", "latest_head", "pr_number"}
if set(record) != required or remote_head is None:
raise PolicyError("task branch adoption record is invalid")
repo = _validate_repo(record["repo"])
ref = _validate_ref(record["ref"], "task branch")
head = _validate_sha(record["latest_head"], "adoption head")
if not all(isinstance(record[name], str) and IDENTIFIER.fullmatch(record[name]) for name in ("board", "root_task_id")):
raise PolicyError("task branch adoption record is invalid")
if not isinstance(record["pr_number"], int) or isinstance(record["pr_number"], bool) or record["pr_number"] < 1:
raise PolicyError("task branch adoption pull request is invalid")
with self._connect() as con:
existing = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (repo, ref)).fetchone()
root = con.execute("select ref from task_roots where repo=? and board=? and root_task_id=?", (repo, record["board"], record["root_task_id"])).fetchone()
if existing is not None:
if tuple(existing[:2]) != (record["board"], record["root_task_id"]):
raise PolicyError("task branch adoption conflicts with ledger")
# A broker-confirmed later revision is legitimate. The static
# import record only proves the first seed, never rewrites it.
return
if head != remote_head:
raise PolicyError("task branch adoption does not match its live head")
if root is not None and root[0] != ref:
raise PolicyError("task branch adoption forks a logical task")
con.execute("insert into task_branches(repo,ref,board,root_task_id,latest_head,pr_number) values(?,?,?,?,?,?)", (repo, ref, record["board"], record["root_task_id"], head, record["pr_number"]))
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (repo, record["board"], record["root_task_id"], ref))
def register(self, claims: dict[str, Any], *, remote_head: str | None) -> None:
"""Create only absent refs; adoption is an explicit operator action."""
with self._connect() as con:
row = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (claims["repo"], claims["ref"])).fetchone()
owner = self._owner(claims)
root = con.execute("select ref from task_roots where repo=? and board=? and root_task_id=?", (claims["repo"], *owner)).fetchone()
if root is not None and root[0] != claims["ref"]:
raise PolicyError("logical task already owns a different branch")
if row is not None and tuple(row[:2]) != owner:
raise PolicyError("task branch is owned by another task")
if row is None:
if remote_head is not None:
if not self._adoption(claims, remote_head):
raise PolicyError("existing unregistered task branch requires operator adoption")
con.execute("insert into task_branches(repo,ref,board,root_task_id,latest_head) values(?,?,?,?,?)", (claims["repo"], claims["ref"], owner[0], owner[1], remote_head))
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (claims["repo"], *owner, claims["ref"]))
return
if claims["expected_old"] != ZERO_SHA:
raise PolicyError("new task branch must use the zero expected head")
con.execute(
"insert into task_branches(repo,ref,board,root_task_id,latest_head) values(?,?,?,?,?)",
(claims["repo"], claims["ref"], owner[0], owner[1], ZERO_SHA),
)
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (claims["repo"], *owner, claims["ref"]))
elif remote_head != row[2]:
raise PolicyError("task branch head changed; fetch and merge before retrying")
def authorize_update(self, claims: dict[str, Any]) -> None:
row = self.get(claims["repo"], claims["ref"])
if row is None:
raise PolicyError("task branch is not registered")
if row[:2] != self._owner(claims):
raise PolicyError("task branch is owned by another task")
if row[2] != claims["expected_old"]:
raise PolicyError("task branch head changed; fetch and merge before retrying")
def authorize_current(self, claims: dict[str, Any]) -> None:
"""Authorize post-push PR prose only at the broker-confirmed new head."""
row = self.get(claims["repo"], claims["ref"])
if row is None or row[:2] != self._owner(claims) or row[2] != claims["new_head"]:
raise PolicyError("task branch head changed; fetch and merge before retrying")
def commit(self, claims: dict[str, Any]) -> None:
self.authorize_update(claims)
with self._connect() as con:
changed = con.execute("update task_branches set latest_head=? where repo=? and ref=? and latest_head=?", (claims["new_head"], claims["repo"], claims["ref"], claims["expected_old"])).rowcount
if changed != 1:
raise PolicyError("task branch changed during broker update")