Conflicts kept both sides: runner.py docstring (W2 board changes and W3 gate changes), signing.py constants (W2 STATUSES and W3 GATE_*), index.html.j2 styles (W1 diff and W3 ancestry). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
16 KiB
Python
350 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
RP_ID = "localhost"
|
|
VERSION = 1
|
|
LIFETIME = 900
|
|
RULE_LIFETIME = 366 * 86400
|
|
ALGORITHMS = {-7: "ES256", -8: "EdDSA", -257: "RS256"}
|
|
HASH = re.compile(r"[0-9a-f]{64}")
|
|
NONCE = re.compile(r"[0-9a-f]{32}")
|
|
ID = re.compile(r"[0-9a-f]{16}")
|
|
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
|
|
OFFICE = re.compile(r"chair|[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}")
|
|
SESSION = re.compile(r"(?!-)[A-Za-z0-9_-]{1,128}")
|
|
WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}")
|
|
HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}")
|
|
OFFICE_ACTIONS = ("appoint", "takeover", "recall")
|
|
OFFICE_KEYS = ("action", "office", "holder", "session", "work")
|
|
TASK = re.compile(r"[a-z0-9][a-z0-9._-]{0,99}(?:/[a-z0-9][a-z0-9._-]{0,99}){0,7}")
|
|
PRIO = re.compile(r"[1-9][0-9]{0,2}")
|
|
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
|
|
ESTIMATOR = "Operator"
|
|
COMMANDS = "operator/"
|
|
STATUSES = ("queued", "running", "blocked", "done", "stopped")
|
|
GATE_LEVELS = ("none", "quick", "test", "quality-high", "e2e")
|
|
GATE_ACTIONS = ("commit", "merge", "push", "pr")
|
|
GATE_REPO = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}")
|
|
GATE_BRANCH = re.compile(r"(?![-/])(?!.*\.\.)(?!.*@\{)(?!.*//)[^\x00-\x20\x7f~^:?*\[\\]{1,255}(?<![/.])")
|
|
TOKEN_FREE_MERGE = "ancestor"
|
|
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
|
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
|
WARNINGS = (
|
|
("deletes recursively", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")),
|
|
("forces", re.compile(r"--force\b|\bpush\b.*\s-f\b")),
|
|
("pushes", re.compile(r"\bgit\b.*\bpush\b")),
|
|
("root", re.compile(r"\bsudo\b|\bdoas\b")),
|
|
("pipes into a shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")),
|
|
("discards changes", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")),
|
|
("Docker deletes", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")),
|
|
)
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
def origin_of(port: int) -> str:
|
|
return f"http://{RP_ID}:{port}"
|
|
|
|
|
|
def b64encode(data: bytes) -> str:
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("ascii")
|
|
|
|
|
|
def challenge(payload: dict) -> str:
|
|
return b64encode(hashlib.sha256(canonical(payload)).digest())
|
|
|
|
|
|
def item_hash(item: dict) -> str:
|
|
return hashlib.sha256(canonical(item)).hexdigest()
|
|
|
|
|
|
def ask_item(ask_id: str, tool: str, command: str, cwd: str, session: str) -> dict:
|
|
return {"kind": "ask", "id": ask_id, "tool": tool, "command": command, "cwd": cwd, "session": session}
|
|
|
|
|
|
def review_item(review_id: str, repo: str, worktree: str, branch: str, tree: str) -> dict:
|
|
return {"kind": "review", "id": review_id, "repo": repo, "worktree": worktree, "branch": branch, "tree": tree}
|
|
|
|
|
|
def operator_item(task: str, command: str, directory: str) -> dict:
|
|
return {"kind": "operator", "id": task, "command": command, "directory": directory}
|
|
|
|
|
|
def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict:
|
|
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
|
|
|
|
|
|
def office_item(action: str, office: str, holder: str, session: str, work: str) -> dict:
|
|
return {"kind": "office", "action": action, "office": office, "holder": holder, "session": session, "work": work}
|
|
|
|
|
|
def office_problem(order: object) -> str | None:
|
|
"""Args:
|
|
order: {"action", "office", "holder", "session", "work"}: appoint
|
|
gives a vacant office to the session, takeover hands the
|
|
holder's office to the session, recall takes it from the
|
|
holder; work is the claim's one-line --work.
|
|
|
|
Returns:
|
|
Why the order is malformed, or None.
|
|
"""
|
|
if not isinstance(order, dict) or set(order) != set(OFFICE_KEYS) or not all(isinstance(order[name], str) for name in OFFICE_KEYS):
|
|
return "an office order holds exactly action, office, holder, session and work as text"
|
|
action, office, holder, session, work = (order[name] for name in OFFICE_KEYS)
|
|
if action not in OFFICE_ACTIONS:
|
|
return f"action must be one of {', '.join(OFFICE_ACTIONS)}"
|
|
if not OFFICE.fullmatch(office):
|
|
return "office must be chair, <dimension>/<line>, cluster/<slug> or work/<slug>"
|
|
if not HOLDER.fullmatch(holder) or (action == "appoint") != (holder == ""):
|
|
return "appoint names no holder, takeover and recall the current one"
|
|
if action == "recall":
|
|
return None if session == work == "" else "a recall names no successor and no work"
|
|
if not SESSION.fullmatch(session) or session == holder:
|
|
return "session must be the successor's session id, not the holder's"
|
|
if not WORK.fullmatch(work):
|
|
return "work must be one line of 1 to 300 characters"
|
|
return None
|
|
|
|
|
|
def rule_request_item(request_id: str, kind: str, tool: str, pattern: str, scope: str, expires: int) -> dict:
|
|
return {"kind": "rule-request", "id": request_id, "rule": kind, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires}
|
|
|
|
|
|
def denial_item(subject: dict, channel: str, reason: str, to: str) -> dict:
|
|
"""Args:
|
|
subject: the item an approval of it would sign.
|
|
channel: the radio class that tells the requesting agent.
|
|
reason: the operator's reason, possibly empty.
|
|
to: the requesting agent's name for a direct channel, "*" for a
|
|
class to all.
|
|
|
|
Returns:
|
|
The item a denial signs: its hash never equals the subject's, so no
|
|
denial signature approves anything.
|
|
"""
|
|
return {"kind": "deny", "subject": item_hash(subject), "channel": channel, "reason": reason, "to": to}
|
|
|
|
|
|
def message_item(to: str, cls: str, text: str, upload: str, voice: str, voice_sha256: str) -> dict:
|
|
return {"kind": "message", "to": to, "class": cls, "text": text, "upload": upload, "voice": voice, "voice_sha256": voice_sha256}
|
|
|
|
|
|
def board_item(action: str, agents: list[str]) -> dict:
|
|
"""Args:
|
|
action: the board action.
|
|
agents: the session ids a night action is limited to, sorted; empty
|
|
for every agent, which keeps the item of a board action without
|
|
agents unchanged.
|
|
|
|
Returns:
|
|
The item the operator's passkey signs.
|
|
"""
|
|
return {"kind": "board", "action": action, **({"agents": agents} if agents else {})}
|
|
|
|
|
|
def stream_item(origins: list[str], urls: list[str]) -> dict:
|
|
return {"kind": "streams", "origins": origins, "urls": urls}
|
|
|
|
|
|
def task_item(task: str, argv: list[str]) -> dict:
|
|
return {"kind": "task", "id": task, "argv": argv}
|
|
|
|
|
|
def board_flags(task: str, flags: list[str]) -> bool:
|
|
if flags == ["--unpin"]:
|
|
return True
|
|
if len(flags) != 2:
|
|
return False
|
|
flag, value = flags
|
|
if flag == "--prio":
|
|
return bool(PRIO.fullmatch(value))
|
|
if flag == "--status":
|
|
return value in STATUSES
|
|
linked = flag in ("--after", "--drop-after")
|
|
return linked and value != task and bool(TASK.fullmatch(value)) and not value.startswith(COMMANDS)
|
|
|
|
|
|
def task_argv(task: object, argv: object) -> list[str]:
|
|
"""Args:
|
|
task: the register task the change is for, a rasci.py slug outside
|
|
operator/.
|
|
argv: the rasci.py arguments, one of `set <task>` with exactly one
|
|
of `--prio <1-999>`, `--unpin`, `--status <status>`,
|
|
`--after <other>` or `--drop-after <other>` (other a register
|
|
slug outside operator/), `estimate <task> Operator --value <fib>
|
|
--complexity <fib>`, `estimate <task> Operator --value <fib>`
|
|
(the operator's business value alone) or
|
|
`estimate <task> Operator --drop`.
|
|
|
|
Returns:
|
|
argv, the hub and the runner check it the same way.
|
|
|
|
Raises:
|
|
Refused for every other task or argv.
|
|
"""
|
|
if not isinstance(task, str) or not TASK.fullmatch(task) or task.startswith(COMMANDS):
|
|
raise Refused("the task is no register task slug outside operator/")
|
|
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
|
|
raise Refused("the argv is no list of texts")
|
|
rest = argv[3:]
|
|
valued = len(rest) in (2, 4) and rest[0] == "--value" and rest[1] in FIBONACCI
|
|
estimated = valued and (len(rest) == 2 or (rest[2] == "--complexity" and rest[3] in FIBONACCI))
|
|
if (argv[:2] == ["set", task] and board_flags(task, argv[2:])) or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])):
|
|
return argv
|
|
raise Refused("the argv is no task priority, status, dependency or WSJF estimate change of this task")
|
|
|
|
|
|
def gate_item(argv: list[str]) -> dict:
|
|
return {"kind": "gate", "argv": argv}
|
|
|
|
|
|
def gate_argv(argv: object) -> list[str]:
|
|
"""Args:
|
|
argv: the gates.py arguments, one of `rule set --repo R --branch B
|
|
--action A --level L`, `rule drop --repo R --branch B --action A`,
|
|
`parents add --repo R --branch B --parent P` (source operator) or
|
|
`parents drop --repo R --branch B --parent P`; R an absolute path,
|
|
B and P branch names, P not B.
|
|
|
|
Returns:
|
|
argv, the hub and the runner check it the same way.
|
|
|
|
Raises:
|
|
Refused for every other argv.
|
|
"""
|
|
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
|
|
raise Refused("the argv is no list of texts")
|
|
head, values = argv[:2], dict(zip(argv[2::2], argv[3::2]))
|
|
names = {("rule", "set"): ("--repo", "--branch", "--action", "--level"), ("rule", "drop"): ("--repo", "--branch", "--action"), ("parents", "add"): ("--repo", "--branch", "--parent"), ("parents", "drop"): ("--repo", "--branch", "--parent")}.get(tuple(head))
|
|
if names is None or argv[2::2] != list(names) or len(argv) != 2 + 2 * len(names):
|
|
raise Refused("the argv is no gates.py rule set|drop or parents add|drop of one branch")
|
|
repo = values["--repo"]
|
|
if not GATE_REPO.fullmatch(repo) or "/../" in f"{repo}/" or "/./" in f"{repo}/":
|
|
raise Refused("the repository is no absolute path")
|
|
branches = [values[name] for name in ("--branch", "--parent") if name in values]
|
|
if not all(GATE_BRANCH.fullmatch(name) for name in branches) or len(set(branches)) != len(branches):
|
|
raise Refused("the branch or parent is no branch name, or the branch is its own parent")
|
|
if values.get("--action", GATE_ACTIONS[0]) not in GATE_ACTIONS or values.get("--level", GATE_LEVELS[0]) not in GATE_LEVELS:
|
|
raise Refused(f"the action must be one of {', '.join(GATE_ACTIONS)} and the level one of {', '.join(GATE_LEVELS)}")
|
|
return argv
|
|
|
|
|
|
def names_default(ref: object, default: object) -> bool:
|
|
"""Args:
|
|
ref: a ref as pkgmgr names it, bare such as main or behind one
|
|
remote such as origin/main, the remote-tracking ref it compares
|
|
against when the remote has a HEAD.
|
|
default: the repository's default branch as the tree file names it,
|
|
without the remote (pkgmgr tree.py strips it).
|
|
|
|
Returns:
|
|
True when ref is the default branch.
|
|
"""
|
|
if not isinstance(ref, str) or not isinstance(default, str) or not default:
|
|
return False
|
|
remote, _, name = ref.partition("/")
|
|
return ref == default or (bool(remote) and name == default)
|
|
|
|
|
|
def token_free(entry: dict, default: object) -> bool:
|
|
"""Args:
|
|
entry: a pkgmgr tree or gc plan item with merged_by, merged_into and
|
|
remotes.
|
|
default: the repository's default branch as the tree file names it.
|
|
|
|
Returns:
|
|
True for a local branch merged by ancestry into the default branch
|
|
(names_default) that has no remote branch: deleting it needs no
|
|
GitHub token.
|
|
"""
|
|
return entry.get("merged_by") == TOKEN_FREE_MERGE and names_default(entry.get("merged_into"), default) and not entry.get("remotes")
|
|
|
|
|
|
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
|
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
|
|
|
|
|
def rule_payload(rule_id: str, kinds: list[str], tool: str, pattern: str, scope: str, expires: int) -> dict:
|
|
return {"v": VERSION, "kind": "rule", "id": rule_id, "kinds": kinds, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires}
|
|
|
|
|
|
def warnings(command: str) -> list[str]:
|
|
return [label for label, pattern in WARNINGS if pattern.search(command)]
|
|
|
|
|
|
def expiry(value: object, now: float, cap: int) -> int:
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise Refused("the expiry is no whole number of epoch seconds")
|
|
if value <= now:
|
|
raise Refused("the signature has expired")
|
|
if value > now + cap:
|
|
raise Refused("the expiry lies too far ahead")
|
|
return value
|
|
|
|
|
|
def checked_approval(payload: object, now: float) -> dict:
|
|
if not isinstance(payload, dict) or set(payload) != APPROVAL_KEYS or payload["v"] != VERSION or payload["kind"] != "approve":
|
|
raise Refused("the payload is no approval")
|
|
items = payload["items"]
|
|
if not isinstance(items, list) or not items or not all(isinstance(entry, str) and HASH.fullmatch(entry) for entry in items):
|
|
raise Refused("the approval lists no item hashes")
|
|
if not isinstance(payload["nonce"], str) or not NONCE.fullmatch(payload["nonce"]):
|
|
raise Refused("the approval holds no nonce")
|
|
expiry(payload["expires"], now, LIFETIME)
|
|
return payload
|
|
|
|
|
|
def checked_rule(payload: object, now: float) -> dict:
|
|
if not isinstance(payload, dict) or set(payload) != RULE_KEYS or payload["v"] != VERSION or payload["kind"] != "rule":
|
|
raise Refused("the payload is no rule")
|
|
if not isinstance(payload["id"], str) or not ID.fullmatch(payload["id"]):
|
|
raise Refused("the rule holds no id")
|
|
kinds = payload["kinds"]
|
|
if not isinstance(kinds, list) or not kinds or not all(isinstance(kind, str) for kind in kinds):
|
|
raise Refused("the rule names no kinds")
|
|
if not isinstance(payload["tool"], str) or not all(isinstance(payload[name], str) and payload[name] for name in ("pattern", "scope")):
|
|
raise Refused("the rule needs a tool text, a pattern and a scope")
|
|
expiry(payload["expires"], now, RULE_LIFETIME)
|
|
return payload
|
|
|
|
|
|
def rule_allows(rule: dict, item: dict, gate: str, now: float) -> bool:
|
|
if rule["expires"] <= now or item["kind"] not in rule["kinds"]:
|
|
return False
|
|
if item["kind"] == "ask":
|
|
named = fnmatch.fnmatchcase(item["tool"], rule["tool"]) and fnmatch.fnmatchcase(item["cwd"], rule["scope"])
|
|
plain = not CHAINED.search(item["command"]) and not warnings(item["command"])
|
|
return named and plain and fnmatch.fnmatchcase(item["command"], rule["pattern"])
|
|
if item["kind"] == "review":
|
|
return gate == "pass" and fnmatch.fnmatchcase(item["branch"], rule["pattern"]) and fnmatch.fnmatchcase(item["worktree"], rule["scope"])
|
|
return False
|
|
|
|
|
|
def load_pinned(path: Path) -> list[dict]:
|
|
try:
|
|
mode = path.stat().st_mode
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return []
|
|
except (OSError, ValueError, RecursionError) as error:
|
|
raise Refused(f"the pinned keys in {path} are unreadable") from error
|
|
if mode & 0o022:
|
|
raise Refused(f"{path} is writable by others")
|
|
entries = data.get("credentials") if isinstance(data, dict) else None
|
|
shaped = isinstance(entries, list) and all(isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("alg") in ALGORITHMS and isinstance(entry.get("spki"), str) for entry in entries)
|
|
if not shaped:
|
|
raise Refused(f"the pinned keys in {path} are malformed")
|
|
return entries
|