feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface

Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked.

Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once.

MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46).

Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 101 %
⏱️ Time: –
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 87
🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 22:20:06 +00:00
1 parent 54f3725aa1
commit 6f940b4415
125 files changed
+1725 -782

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Args:
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
that the Agent Orchestrator settings panel registered (the blob it shows) in
<file>, written with mode 0600. Run it on the host, outside every
sandbox, e.g. --approvers ~/.config/kvb-orchestrator/approvers.json.
verify --port <port> --approvers <file> --replay <file> --review <id>
--gate <pass|fail>: run in the worktree right before the commit;
fetches the review's signature from the Agent Orchestrator on 127.0.0.1:<port>
and checks it against the repository, worktree, branch and the tree
git write-tree prints, as this process sees them.
Returns:
pin: exit code 0 and the pinned credential on stdout; 1 and the reason
on stderr when the blob is no valid registration.
verify: exit code 0 when a pinned key signed this exact commit; 1 and
the reason on stderr otherwise. Commit only on 0.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from hub_api import GUARD, SLACK, base_url, load
from passkey import attested, b64decode
from signing import ALGORITHMS, ID, Refused, origin_of, review_item
from verifier import Trust, check, pin
GIT_TIMEOUT = 30
def git(cwd: Path, args: list[str]) -> str:
try:
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=GIT_TIMEOUT, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise Refused(f"git {args[0]} did not run: {error}") from error
if done.returncode != 0:
raise Refused(f"git {' '.join(args)} failed: {done.stderr.strip()}")
return done.stdout.strip()
def staged(cwd: Path, review_id: str) -> dict:
repo = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
worktree = git(cwd, ["rev-parse", "--show-toplevel"])
branch = git(cwd, ["symbolic-ref", "--short", "HEAD"])
return review_item(review_id, repo, worktree, branch, git(cwd, ["write-tree"]))
def fetch(port: int, path: str) -> dict:
request = urllib.request.Request(f"{base_url(port)}{path}", headers=GUARD)
try:
data = load(request, SLACK)
except (OSError, ValueError) as error:
raise Refused(f"the Agent Orchestrator did not answer: {error}") from error
if not isinstance(data, dict):
raise Refused("the Agent Orchestrator answered no object")
return data
def verify_commit(args: argparse.Namespace) -> str:
review = fetch(args.port, f"/api/reviews/{args.review}")
if review.get("status") != "approved":
raise Refused(f"the review is {review.get('status')}, not approved")
item = staged(Path.cwd(), args.review)
check(review.get("proof"), item, args.gate, Trust(approvers=args.approvers, replay=args.replay, origin=origin_of(args.port)), time.time())
return f"verified: the operator signed review {args.review}, tree {item['tree']} on {item['branch']} in {item['worktree']}"
def pin_credential(args: argparse.Namespace) -> str:
try:
credential = json.loads(b64decode(args.credential, "credential"))
except (ValueError, RecursionError) as error:
raise Refused("the credential blob is no JSON") from error
entry = attested(credential, origin_of(args.port))
entries = pin(args.approvers, entry, time.time())
return f"pinned {entry['id']} ({ALGORITHMS[entry['alg']]}) in {args.approvers}; {len(entries)} key(s) pinned"
def review_id(text: str) -> str:
if not ID.fullmatch(text):
raise argparse.ArgumentTypeError("a review id is 16 hex digits")
return text
def main() -> int:
parser = argparse.ArgumentParser(prog="approval")
commands = parser.add_subparsers(dest="command", required=True)
pinning = commands.add_parser("pin")
verifying = commands.add_parser("verify")
for sub in (pinning, verifying):
sub.add_argument("--port", type=int, required=True)
sub.add_argument("--approvers", type=Path, required=True)
pinning.add_argument("--credential", required=True)
verifying.add_argument("--replay", type=Path, required=True)
verifying.add_argument("--review", type=review_id, required=True)
verifying.add_argument("--gate", choices=("pass", "fail"), required=True)
args = parser.parse_args()
try:
print(pin_credential(args) if args.command == "pin" else verify_commit(args))
except (Refused, OSError) as error:
print(f"refused: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import json
import urllib.request
SLACK = 15.0
GUARD = {"X-Kvb-Orchestrator": "1"}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
def base_url(port: int) -> str:
return f"http://127.0.0.1:{port}"
def load(request: urllib.request.Request, timeout: float) -> object:
with OPENER.open(request, timeout=timeout) as response:
return json.loads(response.read())
def call(request: urllib.request.Request, timeout: float) -> dict:
data = load(request, timeout)
if not isinstance(data, dict):
raise TypeError("the hub answered no object")
return data
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import base64
import hashlib
import json
import re
import subprocess
import tempfile
from pathlib import Path
from signing import RP_ID, Refused, b64encode, challenge
try:
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
except ImportError:
serialization = None
FLAG_UP = 0x01
FLAG_AT = 0x40
EC_PREFIX = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200")
P256_PRIME = 2**256 - 2**224 + 2**192 + 2**96 - 1
P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
ED_PREFIX = bytes.fromhex("302a300506032b6570032100")
RSA_IDENTIFIER = bytes.fromhex("300d06092a864886f70d0101010500")
B64URL = re.compile(r"[A-Za-z0-9_-]+")
ASSERTION_KEYS = ("id", "clientDataJSON", "authenticatorData", "signature")
CBOR_DEPTH = 16
TIMEOUT = 30
def b64decode(text: object, name: str) -> bytes:
if not isinstance(text, str) or not B64URL.fullmatch(text):
raise Refused(f"{name} is no base64url text")
try:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
except ValueError as error:
raise Refused(f"{name} is no base64url text") from error
def cbor(data: bytes, offset: int, depth: int) -> tuple[object, int]:
if depth > CBOR_DEPTH or offset >= len(data):
raise Refused("the CBOR data is truncated or nests too deep")
major, info = data[offset] >> 5, data[offset] & 0x1F
offset += 1
value = info
if 24 <= info <= 27:
size = 1 << (info - 24)
if offset + size > len(data):
raise Refused("the CBOR data is truncated")
value = int.from_bytes(data[offset : offset + size], "big")
offset += size
elif info > 27:
raise Refused("the CBOR data uses an indefinite or reserved length")
if major == 0:
return value, offset
if major == 1:
return -1 - value, offset
if major in (2, 3):
chunk = data[offset : offset + value]
if len(chunk) != value:
raise Refused("the CBOR data is truncated")
if major == 2:
return chunk, offset + value
try:
return chunk.decode("utf-8"), offset + value
except UnicodeDecodeError as error:
raise Refused("the CBOR text is no UTF-8") from error
if major == 4:
items = []
for _ in range(value):
item, offset = cbor(data, offset, depth + 1)
items.append(item)
return items, offset
if major == 5:
entries: dict = {}
for _ in range(value):
key, offset = cbor(data, offset, depth + 1)
if isinstance(key, bool) or not isinstance(key, int | str) or key in entries:
raise Refused("the CBOR map holds a bad or repeated key")
entries[key], offset = cbor(data, offset, depth + 1)
return entries, offset
if major == 7 and info in (20, 21, 22):
return {20: False, 21: True, 22: None}[info], offset
raise Refused("the CBOR data holds an unsupported item")
def der(tag: int, body: bytes) -> bytes:
size = len(body)
if size < 0x80:
return bytes([tag, size]) + body
octets = size.to_bytes((size.bit_length() + 7) // 8, "big")
return bytes([tag, 0x80 | len(octets)]) + octets + body
def der_integer(value: bytes) -> bytes:
digits = value.lstrip(b"\x00") or b"\x00"
return der(0x02, b"\x00" + digits if digits[0] & 0x80 else digits)
def octets(value: object, size: int) -> bool:
return isinstance(value, bytes) and len(value) == size
def p256_point(x: object, y: object) -> bytes | None:
# KeePassXC 2.7.12 drops leading zero bytes of x and y (Botan BigInt as hex), against RFC 9053; pad them back to 32.
if not all(isinstance(value, bytes) and 1 <= len(value) <= 32 for value in (x, y)):
return None
left, right = int.from_bytes(x, "big"), int.from_bytes(y, "big")
if left >= P256_PRIME or right >= P256_PRIME or (right * right - left**3 + 3 * left - P256_B) % P256_PRIME:
return None
return b"\x04" + left.to_bytes(32, "big") + right.to_bytes(32, "big")
def cose_key(key: object) -> tuple[int, bytes]:
if not isinstance(key, dict):
raise Refused("the credential public key is no COSE map")
kty, alg = key.get(1), key.get(3)
if kty == 2 and alg == -7 and key.get(-1) == 1 and (point := p256_point(key.get(-2), key.get(-3))):
return alg, EC_PREFIX + point
if kty == 1 and alg == -8 and key.get(-1) == 6 and octets(key.get(-2), 32):
return alg, ED_PREFIX + key[-2]
if kty == 3 and alg == -257 and isinstance(key.get(-1), bytes) and isinstance(key.get(-2), bytes) and key[-1] and key[-2]:
public = der(0x30, der_integer(key[-1]) + der_integer(key[-2]))
return alg, der(0x30, RSA_IDENTIFIER + der(0x03, b"\x00" + public))
raise Refused("the key is none of ES256 on P-256, EdDSA on Ed25519 or RS256")
def client_data(raw: bytes, kind: str, origin: str) -> dict:
try:
client = json.loads(raw)
except (ValueError, RecursionError) as error:
raise Refused("clientDataJSON is no JSON") from error
if not isinstance(client, dict) or client.get("type") != kind:
raise Refused(f"clientDataJSON is no {kind}")
if client.get("origin") != origin or client.get("crossOrigin") is True:
raise Refused(f"the origin is not {origin}")
return client
def rp_flags(data: bytes, size: int) -> int:
if len(data) < size or data[:32] != hashlib.sha256(RP_ID.encode("ascii")).digest():
raise Refused(f"the rpIdHash is not the one of {RP_ID}")
if not data[32] & FLAG_UP:
raise Refused("the user presence flag is missing")
return data[32]
def attested(credential: object, origin: str) -> dict:
"""Args:
credential: {"id", "attestationObject", "clientDataJSON"} as the
settings panel encodes the navigator.credentials.create answer.
origin: the only origin the registration may come from.
Returns:
The entry to pin: {"id", "alg", "spki"}, the key as base64url DER.
"""
if not isinstance(credential, dict) or set(credential) != {"id", "attestationObject", "clientDataJSON"}:
raise Refused("the credential needs exactly id, attestationObject and clientDataJSON")
raw_id = b64decode(credential["id"], "id")
client_data(b64decode(credential["clientDataJSON"], "clientDataJSON"), "webauthn.create", origin)
blob = b64decode(credential["attestationObject"], "attestationObject")
attestation, end = cbor(blob, 0, 0)
if end != len(blob) or not isinstance(attestation, dict) or not isinstance(attestation.get("authData"), bytes):
raise Refused("the attestationObject holds no authData")
data = attestation["authData"]
if not rp_flags(data, 55) & FLAG_AT:
raise Refused("the registration carries no attested credential")
size = int.from_bytes(data[53:55], "big")
if data[55 : 55 + size] != raw_id:
raise Refused("the attested credential id differs from the id")
key, _ = cbor(data, 55 + size, 0)
alg, spki = cose_key(key)
return {"id": b64encode(raw_id), "alg": alg, "spki": b64encode(spki)}
def by_library(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
try:
key = serialization.load_der_public_key(spki)
if alg == -7 and isinstance(key, ec.EllipticCurvePublicKey) and isinstance(key.curve, ec.SECP256R1):
key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
elif alg == -8 and isinstance(key, ed25519.Ed25519PublicKey):
key.verify(signature, data)
elif alg == -257 and isinstance(key, rsa.RSAPublicKey):
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
else:
return False
except (InvalidSignature, UnsupportedAlgorithm, ValueError, TypeError):
return False
return True
def key_type(alg: int, spki: bytes) -> bool:
if alg == -7:
return len(spki) == len(EC_PREFIX) + 65 and spki.startswith(EC_PREFIX)
if alg == -8:
return len(spki) == len(ED_PREFIX) + 32 and spki.startswith(ED_PREFIX)
return alg == -257 and RSA_IDENTIFIER in spki[:24]
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
if not key_type(alg, spki):
return False
with tempfile.TemporaryDirectory(prefix="kvb-orchestrator-verify-") as folder:
key, message, sealed = (Path(folder) / name for name in ("key.pem", "data.bin", "signature.bin"))
key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii")
message.write_bytes(data)
sealed.write_bytes(signature)
if alg == -8:
argv = ["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin", "-in", str(message), "-sigfile", str(sealed)]
else:
argv = ["openssl", "dgst", "-sha256", "-verify", str(key), "-signature", str(sealed), str(message)]
try:
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
except (OSError, subprocess.TimeoutExpired):
return False
return done.returncode == 0
def verify_signature(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
if serialization is None:
return by_openssl(alg, spki, data, signature)
return by_library(alg, spki, data, signature)
def verify_assertion(assertion: object, payload: dict, credentials: list[dict], origin: str) -> int:
"""Args:
assertion: {"id", "clientDataJSON", "authenticatorData", "signature"}
from navigator.credentials.get, base64url.
payload: the payload whose challenge the operator signed.
credentials: the pinned keys.
origin: the only origin the signature may come from.
Returns:
The authenticator's signature counter.
"""
if not isinstance(assertion, dict) or not all(isinstance(assertion.get(name), str) for name in ASSERTION_KEYS):
raise Refused("the assertion is incomplete")
credential = next((entry for entry in credentials if entry["id"] == assertion["id"]), None)
if credential is None:
raise Refused("the signing key is not pinned")
raw = b64decode(assertion["clientDataJSON"], "clientDataJSON")
if client_data(raw, "webauthn.get", origin).get("challenge") != challenge(payload):
raise Refused("the challenge is not the one of the payload")
data = b64decode(assertion["authenticatorData"], "authenticatorData")
rp_flags(data, 37)
signed = data + hashlib.sha256(raw).digest()
if not verify_signature(credential["alg"], b64decode(credential["spki"], "spki"), signed, b64decode(assertion["signature"], "signature")):
raise Refused("the signature does not verify with the pinned key")
return int.from_bytes(data[33:37], "big")
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Args:
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
http://localhost:<port>.
--timeout: seconds to wait for the operator's decision; keep it below the
hook's timeout in settings.json.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: this hook's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-orchestrator-approvals/hook.json.
stdin: the Claude Code PermissionRequest hook input.
Returns:
stdout: the PermissionRequest decision JSON: allow only when a pinned
passkey signed exactly this tool, command, cwd, session and ask id;
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
error, a missing or invalid signature, a value longer than the hub
takes or a hub that is down, so Claude Code shows its own dialog.
The exit code is 0.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
from pathlib import Path
import hub_api
import signing
import verifier
POLL = 25.0
NAME_LIMIT = 200
COMMAND_LIMIT = 4000
PATH_LIMIT = 4096
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
DENIED = "The operator denied this in the Agent Orchestrator."
def summary(tool_input: object) -> str:
if isinstance(tool_input, dict):
for key in ("command", "file_path", "url", "pattern", "path"):
if isinstance(tool_input.get(key), str):
return tool_input[key]
return json.dumps(tool_input, ensure_ascii=False)
def facts(hook: dict) -> dict:
return {
"tool": str(hook.get("tool_name") or "?"),
"command": summary(hook.get("tool_input")),
"cwd": str(hook.get("cwd") or ""),
"session": str(hook.get("session_id") or ""),
"agent": str(hook.get("agent_type") or ""),
}
def create(base: str, ask: dict, timeout: float) -> str:
payload = {**ask, "timeout": timeout}
headers = {"Content-Type": "application/json", **hub_api.GUARD}
request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST")
ask_id = hub_api.call(request, hub_api.SLACK)["id"]
if not isinstance(ask_id, str) or not signing.ID.fullmatch(ask_id):
raise ValueError("the hub answered no ask id")
return ask_id
def status(base: str, ask_id: str, wait: float) -> dict:
answer = hub_api.call(urllib.request.Request(f"{base}/api/asks/{ask_id}?wait={wait:.1f}"), wait + hub_api.SLACK)
if not isinstance(answer.get("status"), str):
raise TypeError("the hub answered no status")
return answer
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
if answer["status"] == "denied":
return {"behavior": "deny", "message": DENIED}
if answer["status"] != "allowed":
return None
try:
verifier.check(answer.get("proof"), item, "", trust, time.time())
except (signing.Refused, OSError) as error:
print(f"kvb-orchestrator: not signed, Claude Code asks instead: {error}", file=sys.stderr)
return None
return {"behavior": "allow"}
def main() -> int:
parser = argparse.ArgumentParser(prog="permission_hook")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--timeout", type=float, required=True)
parser.add_argument("--approvers", type=Path, required=True)
parser.add_argument("--replay", type=Path, required=True)
args = parser.parse_args()
base = hub_api.base_url(args.port)
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
deadline = time.monotonic() + args.timeout
try:
hook = json.load(sys.stdin)
ask = facts(hook)
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
if too_long:
print(f"kvb-orchestrator: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
return 0
ask_id = create(base, ask, args.timeout)
answer = {"status": "pending"}
while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0:
answer = status(base, ask_id, min(left, POLL))
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return 0
chosen = decision(answer, signing.ask_item(ask_id, ask["tool"], ask["command"], ask["cwd"], ask["session"]), trust)
if chosen is not None:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": chosen}}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Args:
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
http://localhost:<port>.
--interval: seconds between two polls of the hub.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: the runner's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-orchestrator-approvals/runner.json.
--once: handle the queued jobs once and exit.
Returns:
Runs as the operator, outside any sandbox, every operator command a
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
in the signed directory, each signature once and before its expiry.
Refuses every other job. Posts the last 200 output lines back while it
runs and the exit code at its end. The exit code is 0.
"""
from __future__ import annotations
import argparse
import collections
import json
import subprocess
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
import hub_api
import signing
import verifier
LINES = 200
BEAT = 2.0
REFUSED = "refused: no valid operator signature for this command"
def call(base: str, path: str, payload: dict | None) -> dict:
headers = dict(hub_api.GUARD)
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode()
request = urllib.request.Request(f"{base}{path}", data=data, headers=headers, method="GET" if payload is None else "POST")
return hub_api.call(request, hub_api.SLACK)
def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None:
# The reader closes the pipe at EOF: a close in execute() would block on the buffer lock this read holds and SIGPIPE a command still running.
with stream:
for line in stream:
with lock:
tail.append(line)
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
"""Args:
job: the claimed job with task, command, directory and proof.
trust: the pinned keys, the runner's replay store and the origin.
now: the current time in epoch seconds.
Returns:
Why the job must not run, or None when a pinned key signed it.
"""
try:
verifier.check(job.get("proof"), signing.operator_item(job["task"], job["command"], job["directory"]), "", trust, now)
except (signing.Refused, OSError) as error:
return f"{REFUSED}: {error}"
return None
def execute(job: dict, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified job with command and directory.
report: called with {"output", "code", "refused"}; code None while
the command runs.
Returns:
The exit code.
"""
tail: collections.deque = collections.deque(maxlen=LINES)
lock = threading.Lock()
try:
process = subprocess.Popen(
["bash", "-c", job["command"]],
cwd=job["directory"],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
start_new_session=True,
)
except OSError as error:
report({"output": f"could not start: {error}", "code": 127, "refused": False})
return 127
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
reader.start()
while True:
try:
code = process.wait(BEAT)
break
except subprocess.TimeoutExpired:
with lock:
output = "".join(tail)
report({"output": output, "code": None, "refused": False})
reader.join()
report({"output": "".join(tail), "code": code, "refused": False})
return code
def sender(base: str, job_id: str) -> Callable[[dict], None]:
def send(result: dict) -> None:
try:
call(base, f"/api/runner/{job_id}/report", result)
except (OSError, ValueError, TypeError):
return
return send
def cycle(base: str, trust: verifier.Trust) -> None:
for job in call(base, "/api/runner/jobs", None)["jobs"]:
try:
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
except urllib.error.HTTPError:
continue
send = sender(base, claimed["id"])
reason = refusal(claimed, trust, time.time())
if reason is None:
execute(claimed, send)
else:
send({"output": reason, "code": None, "refused": True})
def main() -> int:
parser = argparse.ArgumentParser(prog="runner")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--interval", type=float, required=True)
parser.add_argument("--approvers", type=Path, required=True)
parser.add_argument("--replay", type=Path, required=True)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
base = hub_api.base_url(args.port)
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
while True:
try:
cycle(base, trust)
except (OSError, ValueError, KeyError, TypeError):
pass
if args.once:
return 0
time.sleep(args.interval)
if __name__ == "__main__":
raise SystemExit(main())
+142
View File
@@ -0,0 +1,142 @@
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]")
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = (
("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")),
("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")),
("pusht", re.compile(r"\bgit\b.*\bpush\b")),
("root", re.compile(r"\bsudo\b|\bdoas\b")),
("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")),
("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")),
("Docker löscht", 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 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
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import datetime
import fcntl
import json
import os
import tempfile
from pathlib import Path
from typing import NamedTuple
from passkey import verify_assertion
from signing import (
Refused,
checked_approval,
checked_rule,
item_hash,
load_pinned,
rule_allows,
)
class Trust(NamedTuple):
approvers: Path
replay: Path
origin: str
def pinned(path: Path) -> list[dict]:
entries = load_pinned(path)
if not entries:
raise Refused("no key is pinned")
return entries
def write_private(path: Path, data: dict) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=1, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(name, path)
except OSError:
Path(name).unlink(missing_ok=True)
raise
def pin(path: Path, entry: dict, now: float) -> list[dict]:
stamp = datetime.datetime.fromtimestamp(now, datetime.UTC).isoformat(timespec="seconds")
entries = [old for old in load_pinned(path) if old["id"] != entry["id"]] + [{**entry, "pinned": stamp}]
write_private(path, {"credentials": entries})
return entries
def read_store(store: Path) -> dict:
try:
data = json.loads(store.read_text(encoding="utf-8"))
except FileNotFoundError:
return {"used": {}, "counts": {}}
except (OSError, ValueError, RecursionError) as error:
raise Refused(f"the replay store {store} is unreadable") from error
counts = data.get("counts") if isinstance(data, dict) else None
shaped = isinstance(counts, dict) and all(isinstance(last, dict) and isinstance(last.get("count"), int) and isinstance(last.get("nonce"), str) for last in counts.values())
if not shaped or not isinstance(data.get("used"), dict):
raise Refused(f"the replay store {store} is malformed")
return data
def consume(store: Path, key: str, expires: int, credential: str, count: int, nonce: str, now: float) -> None:
"""Args:
store: the executor's own replay store, outside the hub's reach.
key: the one-time id, nonce and item hash.
expires: until when the id must stay known.
credential: the id of the key that signed.
count: its signature counter; 0 when the authenticator keeps none.
nonce: the payload nonce; one bulk signature carries one counter.
now: the current time in epoch seconds.
"""
store.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor = os.open(store.with_name(f"{store.name}.lock"), os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX)
state = read_store(store)
used = {name: until for name, until in state["used"].items() if isinstance(until, int | float) and until > now}
if key in used:
raise Refused("this signature was already used for this item")
last = state["counts"].get(credential)
if last is not None and not (count > last["count"] or (count == last["count"] and (count == 0 or nonce == last["nonce"]))):
raise Refused("the signature counter did not grow: a cloned authenticator or a replay")
write_private(store, {"used": {**used, key: expires}, "counts": {**state["counts"], credential: {"count": count, "nonce": nonce}}})
finally:
os.close(descriptor)
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
"""Args:
proof: {"payload", "assertion"} the Agent Orchestrator relayed for the item.
item: the executor's own item, built from what it is about to do.
gate: pass or fail as the executor itself saw it; a rule approves a
review on pass only.
trust: the pinned keys, the executor's replay store and the origin.
now: the current time in epoch seconds.
Raises:
Refused with the reason, unless a pinned key signed an unexpired
approval that lists the item and is used here for the first time, or
an unexpired rule whose pattern the item matches.
"""
if not isinstance(proof, dict) or set(proof) != {"payload", "assertion"}:
raise Refused("no signature")
payload = proof["payload"]
if isinstance(payload, dict) and payload.get("kind") == "rule":
checked_rule(payload, now)
verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
if not rule_allows(payload, item, gate, now):
raise Refused("the signed rule does not match this item")
return
checked_approval(payload, now)
count = verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
digest = item_hash(item)
if digest not in payload["items"]:
raise Refused("the signature does not list this item")
consume(trust.replay, f"{payload['nonce']}:{digest}", payload["expires"], proof["assertion"]["id"], count, payload["nonce"], now)