feat(orchestrator): inbox console, deny by channel and agent rule requests

The inbox shows the console output of a command under its "Ausführen"
row, kept to the same tail the job list shows. A deny picks its radio
channel (post by default, call, newsletter, broadcast, pan-pan,
security) and an optional reason; the passkey signs denial, channel and
reason, the hub verifies the proof before it stores or sends anything,
and the PermissionRequest hook quotes a reason only when it verifies.

Agents ask for an auto-approval rule through the MCP tool rule_request;
the request shows in the inbox with a dry run over the last seven days
and is accepted only with a passkey assertion the hub verifies against
the pinned keys and the hub origin, for every rule kind. Requests lapse
with the ask lifetime and are capped per agent and in total.

The image runs as nobody by default and carries the health check
command; compose keeps the timing.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 98 %
⏱️ Time: 75 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 18:14:30 +00:00
1 parent 1d311a3382
commit 58187e8323
37 files changed
+1387 -100

No files matched your search

+4 -2
View File
@@ -12,7 +12,7 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install --yes --no-install-recommends git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install --yes --no-install-recommends git openssl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --root-user-action=ignore --requirement /app/requirements.txt
@@ -36,8 +36,10 @@ RUN chmod 0755 /opt/voices /opt/whisper
COPY server.py /app/server.py
COPY kvb_orchestrator /app/kvb_orchestrator
COPY client/signing.py /app/client/signing.py
COPY client/signing.py client/passkey.py client/verifier.py /app/client/
COPY templates /app/templates
COPY vendor /app/vendor
USER 65534:65534
HEALTHCHECK CMD ["python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 2).close()"]
ENTRYPOINT ["python3", "/app/server.py"]
+18 -2
View File
@@ -12,7 +12,8 @@
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,
deny when the operator denied in the Agent Orchestrator, with the
reason only when a pinned passkey signed it. 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.
@@ -22,6 +23,7 @@ from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.request
@@ -37,6 +39,8 @@ 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."
REASON_LIMIT = 2000
CONTROL = re.compile(r"[\x00-\x1f\x7f]")
def summary(tool_input: object) -> str:
@@ -74,9 +78,21 @@ def status(base: str, ask_id: str, wait: float) -> dict:
return answer
def denied(answer: dict, item: dict, trust: verifier.Trust) -> str:
reason, channel = answer.get("reason"), answer.get("channel")
if not isinstance(reason, str) or not isinstance(channel, str) or not reason.strip():
return DENIED
try:
verifier.check(answer.get("proof"), signing.denial_item(item, channel, reason), "", trust, time.time())
except (signing.Refused, OSError) as error:
print(f"kvb-orchestrator: the reason is not signed, the agent gets the denial without it: {error}", file=sys.stderr)
return DENIED
return f"{DENIED} Reason: {CONTROL.sub('?', reason.strip())[:REASON_LIMIT]}"
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
if answer["status"] == "denied":
return {"behavior": "deny", "message": DENIED}
return {"behavior": "deny", "message": denied(answer, item, trust)}
if answer["status"] != "allowed":
return None
try:
+17
View File
@@ -107,6 +107,23 @@ def office_problem(order: object) -> str | None:
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) -> 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.
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}
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
-1
View File
@@ -15,7 +15,6 @@ services:
mem_limit: 2g
pids_limit: 256
healthcheck:
test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 2).close()"]
interval: 30s
timeout: 5s
retries: 3
+62 -18
View File
@@ -5,17 +5,44 @@ import time
from .asking import ask_state
from .cleanup import cleanup_request
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field
from .offices import office_request
from .records import digest, shown
from .register import REGISTER, operator_rows, read_register, runnable
from .rules import rule_matches
from .signed import ask_signed, operator_signed, review_signed, signing
from .rules import rule_matches, subject_of
from .signed import (
ask_signed,
operator_signed,
request_signed,
review_signed,
signing,
verifier,
)
BATCH_LIMIT = 100
INBOX_KINDS = ("review", "ask", "decision", "operator")
INBOX_KINDS = ("review", "ask", "decision", "operator", "rule")
VERBS = ("approve", "reject")
ITEM_KEYS = ("kind", "id", "verb", "label", "sha256")
def inbox_item(raw: dict, label: str, title: str, gate: str, subject: dict | None) -> dict:
"""Args:
raw: the page's entry, already checked for its keys and types.
label: the chosen option of a decision, else empty.
title: what the stream logs for the item.
gate: the review gate, else empty.
subject: what an approval signs; None for a decision.
Returns:
The batch item. A denial of a deniable kind signs its own item and
carries the note that tells the requesting agent.
"""
note = denial_note(raw) if raw["verb"] == "reject" and raw["kind"] in DENIABLE else None
signed = subject if raw["verb"] == "approve" else None if note is None else signing.denial_item(subject, note["channel"], note["reason"])
sha256 = raw["sha256"] if raw["kind"] == "operator" else ""
return {"kind": raw["kind"], "id": raw["id"], "verb": raw["verb"], "label": label, "sha256": sha256, "title": shown(title)[:200], "gate": gate, "signed": signed, "note": note}
class Approving:
@@ -34,7 +61,8 @@ class Approving:
Returns:
The proof to relay, None when nothing needs a signature. The hub
checks the shape only; the executors verify the signature.
verifies the signature of a denial itself, since it acts on it;
of an approval it checks the shape, and the executors verify.
"""
if not signed:
if proof is not None:
@@ -49,16 +77,28 @@ class Approving:
raise ToolError(f"proof: {error}") from None
if not isinstance(proof["assertion"], dict) or any(signing.item_hash(item) not in payload["items"] for item in signed):
raise ToolError("proof must carry the assertion and list every approved item")
for item in signed:
if item["kind"] == "deny":
self.proven(proof, item)
return {"payload": payload, "assertion": proof["assertion"]}
def automate(self, kind_name: str, record: dict, subject: dict) -> None:
def proven(self, proof: object, item: dict) -> dict:
trust = verifier.Trust(approvers=self.approvers, replay=self.replay, origin=signing.origin_of(self.public_port))
try:
verifier.check(proof, item, "", trust, time.time())
except signing.Refused as error:
raise ToolError(f"nicht signiert: {error}") from None
return proof
def automate(self, kind_name: str, record: dict) -> None:
now = time.time()
subject = subject_of(kind_name, record)
rule = next((rule for rule in self.rules.listed() if rule_matches(rule, kind_name, subject, now)), None)
if rule is None:
return
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
signed = None if kind_name == "decision" else subject["item"]
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed, "note": None}
batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply)
words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ")
self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}})
@@ -76,6 +116,8 @@ class Approving:
def settle_item(self, item: dict, proof: dict | None) -> str:
approve = item["verb"] == "approve"
if item["note"] is not None:
return self.deny(item, proof)
if item["kind"] == "operator":
return self.queue_job(item, proof)
if item["kind"] == "ask":
@@ -93,27 +135,29 @@ class Approving:
def item_of(self, raw: object, tasks: dict[str, dict]) -> dict:
if not isinstance(raw, dict):
raise ToolError("every item must be an object with kind, id, verb, label and sha256")
expect(raw, ("kind", "id", "verb", "label", "sha256"))
kind_name, verb = raw["kind"], raw["verb"]
raise ToolError("every item must be an object with kind, id, verb, label and sha256, and a denial also channel and reason")
kind_name, verb = raw.get("kind"), raw.get("verb")
expect(raw, (*ITEM_KEYS, *DENY_KEYS) if verb == "reject" and kind_name in DENIABLE else ITEM_KEYS)
if kind_name not in INBOX_KINDS or verb not in VERBS or not isinstance(raw["label"], str) or not isinstance(raw["sha256"], str):
raise ToolError(f"kind must be one of {', '.join(INBOX_KINDS)}, verb approve or reject, label and sha256 text")
if kind_name == "operator":
row = next((row for row in operator_rows(tasks) if row["task"] == raw["id"]), None)
if row is None:
raise Missing(str(raw["id"]))
if verb != "approve" or not runnable(row):
raise ToolError("an operator row is approved only, and only when it holds a directory and a command")
if verb == "approve" and not runnable(row):
raise ToolError("an operator row is approved only when it holds a directory and a command")
if digest(row["command"]) != raw["sha256"]:
raise Conflict({"id": row["task"], "status": "changed"})
if row["task"] in self.open_jobs():
raise Conflict({"id": row["task"], "status": "queued"})
return {"kind": kind_name, "id": row["task"], "verb": verb, "label": "", "sha256": raw["sha256"], "title": row["command"][:200], "gate": "", "signed": operator_signed(row)}
return inbox_item(raw, "", row["command"], "", operator_signed(row))
if kind_name == "rule" and verb == "approve":
raise ToolError("a rule request is accepted in its row, with the passkey signature over the rule itself")
item_id = field(raw, "id")
record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions}[kind_name].get(item_id)
record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions, "rule": self.rule_requests}[kind_name].get(item_id)
if record is None:
raise Missing(item_id)
status = ask_state(record, time.time())["status"] if kind_name == "ask" else record["status"]
status = ask_state(record, time.time())["status"] if kind_name == "ask" else request_status(record, time.time()) if kind_name == "rule" else record["status"]
if status != "pending":
raise Conflict({"id": item_id, "status": status})
label = ""
@@ -121,11 +165,11 @@ class Approving:
if verb != "approve" or raw["label"] not in [option["label"] for option in record["options"]]:
raise ToolError("a decision is approved with one of its option labels")
label = raw["label"]
if kind_name == "rule":
return inbox_item(raw, "", f"Regel {request_words(record)}", "", request_signed(record))
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
signed = None
if verb == "approve" and kind_name != "decision":
signed = ask_signed(record) if kind_name == "ask" else review_signed(record)
return {"kind": kind_name, "id": item_id, "verb": verb, "label": label, "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None
return inbox_item(raw, label, title, record.get("gate", ""), subject)
def checked(self, raw: object) -> list[dict]:
if not isinstance(raw, list) or not 1 <= len(raw) <= BATCH_LIMIT:
+3 -3
View File
@@ -26,7 +26,7 @@ ANSWERS = {"allow": "allowed", "deny": "denied"}
def ask_state(ask: dict, now: float) -> dict:
status = "expired" if ask["status"] == "pending" and ask["expires"] <= now else ask["status"]
return {"id": ask["id"], "status": status, "decided": ask["decided"], "proof": ask.get("proof")}
return {"id": ask["id"], "status": status, "decided": ask["decided"], "proof": ask.get("proof"), "channel": ask.get("channel", ""), "reason": ask.get("reason", "")}
def ask_row(ask: dict, names: dict[str, str]) -> dict:
@@ -59,7 +59,7 @@ class Asking:
created = time.time()
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None}
record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime})
self.automate("ask", record, {"item": ask_signed(record), "gate": ""})
self.automate("ask", record)
return {"id": record["id"], "status": record["status"]}
def ask_wait(self, ask_id: str, query: dict[str, list[str]]) -> dict:
@@ -116,7 +116,7 @@ class Asking:
raise ToolError("recommended must be one of the option labels or an empty string")
fields = {"question": question, "options": parsed, "agent": field(arguments, "agent").strip(), "recommended": recommended.strip()}
record = self.decisions.add({**fields, "status": "pending", "choice": None, "decided": None, "created_ts": time.time()})
self.automate("decision", record, {"recommended": record["recommended"], "agent": record["agent"]})
self.automate("decision", record)
return {"id": record["id"], "status": record["status"], "url": self.url()}
def decision_wait(self, arguments: dict) -> dict:
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import time
from .asking import ASK_LIFETIME, ask_state
from .council import HUMAN, read_claims
from .errors import ToolError
from .messaging import DIRECT, TO_ALL
from .register import REGISTER, adder, operator_rows, read_register
from .signed import operator_signed, signing
from .skill_scripts import RADIO, RASCI
DENIABLE = ("ask", "operator", "rule")
DENY_KEYS = ("channel", "reason")
DENY_CHANNELS = {**DIRECT, **{name: words for name, words in TO_ALL.items() if name != "mayday"}}
REASON_LIMIT = 2000
def denial_note(raw: dict) -> dict:
channel, reason = raw["channel"], raw["reason"]
if not isinstance(channel, str) or channel not in DENY_CHANNELS:
raise ToolError(f"channel must be one of {', '.join(DENY_CHANNELS)}")
if not isinstance(reason, str) or len(reason) > REASON_LIMIT:
raise ToolError(f"reason must be text of at most {REASON_LIMIT} characters")
return {"channel": channel, "reason": " ".join(reason.split())}
def denial_text(what: str, requester: str, reason: str) -> str:
words = f"Denied by the operator: {what} (from {requester or 'an agent'})."
return f"{words} Reason: {reason}" if reason else words
def request_words(record: dict) -> str:
return " ".join(part for part in (record["kind"], record["tool"], record["pattern"]) if part) + f" in {record['scope']}"
def request_status(record: dict, now: float) -> str:
lapsed = record["created_ts"] + ASK_LIFETIME <= now or record["expires"] <= now
return "expired" if record["status"] == "pending" and lapsed else record["status"]
class Denying:
def deny(self, item: dict, proof: dict | None) -> str:
"""Args:
item: a checked inbox item with verb reject and its note.
proof: the relayed signature over the denial item.
Returns:
The outcome words for the stream: what changed and whom the
radio told.
"""
note = item["note"]
if item["kind"] == "operator":
tasks = read_register(self.council / REGISTER)
row = next((row for row in operator_rows(tasks) if row["task"] == item["id"]), None)
if row is None or signing.denial_item(operator_signed(row), note["channel"], note["reason"]) != item["signed"]:
return "verweigert: die Zeile hat sich seit der Ablehnung geändert"
if item["id"] in self.open_jobs():
return "läuft schon"
run = self.scripts.run(RASCI, ["set", item["id"], "--status", "stopped"])
if run["code"] != 0:
return f"fehlgeschlagen: {run['command']} → {run['code']}"
command, directory = row["command"][:200], row["directory"]
return f"gestoppt · {self.tell(note, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}"
ledger = self.asks if item["kind"] == "ask" else self.rule_requests
record = ledger.get(item["id"])
if record is None:
return "unbekannt"
now = time.time()
if (ask_state(record, now)["status"] if item["kind"] == "ask" else request_status(record, now)) == "expired":
return "abgelaufen"
record, changed = ledger.decide(item["id"], {"status": "denied", "proof": proof, **note})
if not changed:
return f"schon {record['status']}"
if item["kind"] == "ask":
tool, command = record["tool"], record["command"][:200]
return f"abgelehnt · {self.tell(note, proof, record['session'], f'ask {tool}: {command}')}"
return f"abgelehnt · {self.tell(note, proof, record['agent'], f'rule request {request_words(record)}')}"
def tell(self, note: dict, proof: dict | None, who: str, what: str) -> str:
"""Args:
note: the chosen channel and the reason.
proof: the operator's signature over the denial, kept with the
message operator_message proves.
who: the requesting agent's name or session; empty when unknown.
what: what was denied, in the agent's words.
Returns:
The class and addressee for the outcome line.
"""
claim = next((claim for claim in read_claims(self.council) if who and claim["address"] and who in (claim["agent"], claim["session"])), None)
name, address = (claim["agent"], claim["address"]) if claim else (who, who)
channel = note["channel"]
direct = channel in DIRECT
if direct and not address:
return f"kein Empfänger für {channel}"
text = denial_text(what, name, note["reason"])
to, where = (address, f"{channel} an {address}") if direct else ("*", f"{channel} an alle")
record = self.messages.add({"to": name if direct else "*", "address": to, "text": text, "upload": None, "class": channel, "proof": proof, "created_ts": time.time()})
targets = ["--to", address] if direct else []
run = self.scripts.run(RADIO, [*DENY_CHANNELS[channel], *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])
return where if run["code"] == 0 else f"{where} fehlgeschlagen ({run['code']})"
+10 -2
View File
@@ -27,9 +27,10 @@ STORED = {
"files": (".json", "application/json", PLAIN_CSP),
}
DECISIONS = {"approve": "approved", "reject": "rejected"}
INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark)")
INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark|dismiss)")
UNDO_ROUTE = re.compile(r"/api/inbox/undo/([0-9a-f]{16})")
RULE_ROUTE = re.compile(r"/api/rules/([0-9a-f]{16})/(enable|disable|delete)")
REQUEST_ROUTE = re.compile(r"/api/rule-requests/([0-9a-f]{16})/(sign|accept)")
RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
SIGN_ROUTE = "/api/sign"
@@ -122,8 +123,15 @@ class Handler(Exchange):
inbox = INBOX_ROUTE.fullmatch(path)
undo = UNDO_ROUTE.fullmatch(path)
rule = RULE_ROUTE.fullmatch(path)
request = REQUEST_ROUTE.fullmatch(path)
if inbox and inbox.group(1) == "approve":
return lambda body: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED)
if inbox and inbox.group(1) == "dismiss":
return lambda body: self.answer(lambda: hub.dismiss(body), HTTPStatus.OK)
if request and request.group(2) == "sign":
return lambda _body: self.answer(lambda: hub.sign_request(request.group(1)), HTTPStatus.OK)
if request:
return lambda body: self.answer(lambda: hub.accept_request(request.group(1), body), HTTPStatus.CREATED)
if path == SIGN_ROUTE:
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
if path == RULE_SIGN_ROUTE:
@@ -151,7 +159,7 @@ class Handler(Exchange):
return None
def posted(self, path: str) -> bool:
return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, RUNNER_ROUTE))
return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, REQUEST_ROUTE, RUNNER_ROUTE))
def runner(self, path: str, body: bytes) -> None:
hub = self.server.hub
+7 -1
View File
@@ -12,6 +12,7 @@ from .artifacts import Touched
from .asking import Asking
from .batches import Pending
from .cleanup import Cleaning, Spool
from .denying import Denying
from .engines import Engines, Speech
from .events import Log, Watch
from .icons import sheet
@@ -23,6 +24,7 @@ from .mcp import Mcp
from .messaging import Messaging
from .names import Names
from .offices import Offices
from .requesting import Requesting
from .reviewing import Reviewing
from .rules import Rules
from .settings import Settings
@@ -57,7 +59,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
@@ -65,6 +67,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox
asks: Ledger
decisions: Ledger
messages: Ledger
rule_requests: Ledger
uploads: Path
host_state: Path
transcripts: Transcripts
@@ -88,6 +91,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox
spool: Spool
office_spool: Spool
touched: Touched
replay: Path
def hosts(self) -> tuple[str, str]:
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
@@ -108,6 +112,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
touched=Touched(reviews.stored),
approvers=approvers,
speech=Speech(engines, state / "tts"),
replay=state / "replay" / "messages.json",
rules=Ledger(state / "rules.json"),
jobs=Ledger(state / "jobs.json"),
marks=Marks(state / "marks.json"),
@@ -126,6 +131,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
asks=Ledger(state / "asks.json"),
decisions=Ledger(state / "decisions.json"),
messages=Ledger(state / "messages.json"),
rule_requests=Ledger(state / "rule-requests.json"),
uploads=uploads,
host_state=host_state,
transcripts=Transcripts(transcripts),
+17 -7
View File
@@ -5,9 +5,10 @@ import time
from .approving import INBOX_KINDS
from .errors import ToolError
from .fields import decode, expect
from .jobs import console_of
from .names import Directory
from .records import digest, iso_epoch
from .register import operator_rows, runnable
from .register import adder, operator_rows, runnable
from .signed import signing
SNOOZE_LIMIT = 8 * 86400
@@ -15,7 +16,7 @@ SNOOZE_LIMIT = 8 * 86400
class Inbox:
def inbox(self, now: float, tasks: dict[str, dict], directory: Directory) -> list[dict]:
base = {"agent": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False}
base = {"agent": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False, "deniable": False, "job": None, "rule": None, "dry_run": None}
items = []
for review in self.reviews.pending():
created = iso_epoch(review["created"]) or now
@@ -24,17 +25,26 @@ class Inbox:
items.append({**base, "kind": "review", "id": review["id"], "ts": created, **fields})
for ask in (ask for ask in self.asks.pending() if ask["expires"] > now):
warnings = signing.warnings(ask["command"])
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "green": not warnings, "warnings": warnings}
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "green": not warnings, "warnings": warnings, "deniable": True}
items.append({**base, "kind": "ask", "id": ask["id"], "ts": ask["created_ts"], **fields, "command": ask["command"], "directory": ask["cwd"]})
for decision in self.decisions.pending():
recommended = decision.get("recommended", "")
fields = {"title": decision["question"][:300], "agent": decision["agent"], "green": recommended != "", "options": decision["options"], "recommended": recommended}
items.append({**base, "kind": "decision", "id": decision["id"], "ts": decision["created_ts"], **fields})
held = self.open_jobs()
for row in (row for row in operator_rows(tasks) if row["task"] not in held):
ok = runnable(row)
fields = {"title": row["command"], "detail": row["why"], "command": row["command"], "directory": row["directory"], "sha256": digest(row["command"]) if ok else "", "runnable": ok}
items.append({**base, "kind": "operator", "id": row["task"], "ts": self.watch.first(row["task"], now), **fields})
consoles = self.consoles()
rows = operator_rows(tasks)
for row in rows:
busy = row["task"] in held
fields = {"title": row["command"], "detail": row["why"], "agent": adder(tasks[row["task"]]), "command": row["command"], "directory": row["directory"], "sha256": digest(row["command"])}
state = {"runnable": runnable(row) and not busy, "deniable": not busy, "job": console_of(consoles.get(row["task"]))}
items.append({**base, "kind": "operator", "id": row["task"], "ts": self.watch.first(row["task"], now), **fields, **state})
listed = {row["task"] for row in rows}
for task, job in consoles.items():
if task not in listed:
fields = {"title": job["command"], "command": job["command"], "directory": job["directory"], "job": console_of(job)}
items.append({**base, "kind": "operator", "id": task, "ts": job["created_ts"], **fields})
items += self.request_items(base, now)
for item in items:
mark = self.marks.get(f"{item['kind']}:{item['id']}")
item.update(pinned=mark["pinned"], until=mark["until"] if mark["until"] > now else 0.0, held=self.batches.holds(item["kind"], item["id"]))
+27 -3
View File
@@ -3,23 +3,47 @@ from __future__ import annotations
import time
from .errors import Conflict, Missing, ToolError
from .fields import bounded, decode, expect
from .fields import bounded, decode, expect, field
from .register import OPERATOR, REGISTER, operator_rows, read_register, runnable
from .signed import operator_signed
from .skill_scripts import OPERATOR_SCRIPT
from .skill_scripts import OPERATOR_SCRIPT, OUTPUT_LIMIT
JOB_OPEN = ("queued", "running")
RUN_OUTPUT = 64 << 10
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
def runner_job(job: dict) -> dict:
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")}
def console_of(job: dict | None) -> dict | None:
return None if job is None else {**{key: job[key] for key in CONSOLE}, "output": job["output"][-OUTPUT_LIMIT:]}
class Jobs:
def open_jobs(self) -> set[str]:
return {job["task"] for job in self.jobs.listed() if job["status"] in JOB_OPEN}
def consoles(self) -> dict[str, dict]:
latest: dict[str, dict] = {}
for job in sorted(self.jobs.listed(), key=lambda job: job["created_ts"]):
latest[job["task"]] = job
# Trip-wire: jobs queued before the inbox console carry no dismissed key and must not flood the inbox after an upgrade.
return {task: job for task, job in latest.items() if job.get("dismissed") is False}
def dismiss(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("id",))
job_id = field(payload, "id")
record = self.jobs.get(job_id)
if record is None:
raise Missing(job_id)
if record["status"] in JOB_OPEN:
raise Conflict({"id": job_id, "status": record["status"]})
self.jobs.change(job_id, {"dismissed": True})
return {"id": job_id, "dismissed": True}
def queue_job(self, item: dict, proof: dict | None) -> str:
row = next((row for row in operator_rows(read_register(self.council / REGISTER)) if row["task"] == item["id"]), None)
if row is None or not runnable(row) or operator_signed(row) != item["signed"]:
@@ -27,7 +51,7 @@ class Jobs:
if item["id"] in self.open_jobs():
return "läuft schon"
job = {"task": row["task"], "command": row["command"], "directory": row["directory"], "sha256": item["sha256"], "status": "queued", "proof": proof}
self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None, "dismissed": False})
return "an den Runner übergeben"
def runner_jobs(self) -> dict:
+8 -4
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from pathlib import Path
from .council import NEWS, ordered, read_claims
from .denying import DENIABLE
from .errors import ToolError
from .events import VIEWS
from .fields import bounded, decode, expect, message_text
@@ -29,7 +30,7 @@ SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
NL_ACTIONS = [
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
{"name": "reject", "description": "Reject reviews or deny asks.", "parameters": {"ids": IDS}},
{"name": "reject", "description": "Reject reviews, or deny asks, operator rows and rule requests; the requesting agent gets a post.", "parameters": {"ids": IDS}},
{"name": "choose", "description": "Pick an option label of a decision.", "parameters": {"id": {"type": "string"}, "label": {"type": "string"}}},
{"name": "snooze", "description": "Hide inbox items for some hours.", "parameters": {"ids": IDS, "hours": {"type": "number", "minimum": 1, "maximum": 192}}},
{"name": "pin", "description": "Pin inbox items to the top.", "parameters": {"ids": IDS}},
@@ -62,7 +63,8 @@ def nl_ids(arguments: dict, items: dict[str, dict]) -> list[dict]:
def nl_item(item: dict, verb: str, label: str) -> dict:
return {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
entry = {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
return {**entry, "channel": "post", "reason": ""} if verb == "reject" and item["kind"] in DENIABLE else entry
def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
@@ -75,8 +77,10 @@ def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
expect(arguments, tuple(schema["parameters"]))
if name in ("approve", "reject"):
picked = nl_ids(arguments, items)
if name == "reject" and any(item["kind"] not in ("review", "ask") for item in picked):
raise ToolError("reject takes reviews and asks only")
if name == "reject" and any(item["kind"] not in ("review", *DENIABLE) or (item["kind"] != "review" and not item["deniable"]) for item in picked):
raise ToolError("reject takes reviews, asks, operator rows and rule requests that wait")
if name == "approve" and any(item["kind"] == "rule" for item in picked):
raise ToolError("a rule request is accepted in its row with the passkey")
if name == "approve" and any(item["kind"] == "decision" and not item["recommended"] for item in picked):
raise ToolError("a decision without a recommended option needs choose")
if name == "approve" and any(item["kind"] == "operator" and not item["runnable"] for item in picked):
+12
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import secrets
import threading
from collections.abc import Callable
from pathlib import Path
from .records import read_json, stamp, write_atomic
@@ -37,6 +38,17 @@ class Ledger:
with self.changed:
return self.insert(self.fresh(), fields)
def add_checked(self, fields: dict, check: Callable[[list[dict]], None]) -> dict:
"""Args:
fields: the record to add.
check: raises to refuse the record; it sees every record under
the lock the add holds, so parallel callers cannot pass a
cap together.
"""
with self.changed:
check(self.listed())
return self.add(fields)
def get(self, item_id: str) -> dict | None:
with self.changed:
record = self.items.get(item_id)
+26
View File
@@ -7,7 +7,9 @@ from . import NAME
from .asking import OPTION_LIMIT
from .errors import RpcError, ToolError
from .fields import WAIT_CAP
from .requesting import AGENT_CAP
from .reviewing import GATES
from .rules import RULE_KINDS
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
@@ -77,6 +79,28 @@ class Mcp:
f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.",
{"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait},
),
tool(
"rule_request",
"Ask the operator for an auto-approval rule in the Agent Orchestrator. Nothing is stored until the operator "
"signs the rule with the passkey; the limits are those of the rules form, and an ask rule never covers a "
f"chained command or one with a warning. At most {AGENT_CAP} requests of one agent wait at a time. "
"Returns the request id and the hub URL; a denial also reaches you over the radio with the reason.",
{
"kind": {"type": "string", "enum": list(RULE_KINDS), "description": "ask: a tool and command pattern; review: a branch pattern, green gate only; decision: take the recommended option."},
"tool": text_property("Tool glob of an ask rule, e.g. Bash; empty for review and decision."),
"pattern": text_property("One-line glob over the command (ask), the branch (review) or the agent name (decision)."),
"scope": text_property("One-line glob over the working directory (ask) or the worktree (review); * for decision."),
"expires": {"type": "integer", "description": "Epoch seconds the rule ends, at most 366 days ahead."},
"agent": text_property("Your agent name, shown with the request."),
"why": text_property("Why the rule helps, plain text for the operator."),
},
),
tool(
"rule_request_wait",
f"Wait until the operator accepts or denies a rule request or the timeout passes, then return its status "
f"(pending, accepted, denied), the stored rule id and the operator's reason. Waits at most {WAIT_CAP} seconds.",
{"id": text_property("Request id that rule_request returned."), "timeout_seconds": wait},
),
tool(
"operator_message",
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
@@ -102,6 +126,8 @@ class Mcp:
"review_list": self.pending,
"decision_request": self.decision_request,
"decision_wait": self.decision_wait,
"rule_request": self.rule_request,
"rule_request_wait": self.rule_request_wait,
"operator_message": self.operator_message,
}
name = params.get("name")
+5 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path
from .council import agent, ordered
from .council import HUMAN, agent, ordered
from .fields import ORDER_NAME
from .records import read_json, shown
@@ -45,6 +45,10 @@ def parties(row: dict) -> dict[str, str]:
return found
def adder(row: dict) -> str:
return next((name for name, letters in parties(row).items() if "A" in letters and name != HUMAN), "")
def is_open(task: str, row: dict, operator: bool) -> bool:
return task.startswith(OPERATOR) == operator and status_of(row) not in ENDED
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import time
from .denying import request_status, request_words
from .errors import Conflict, Missing, ToolError
from .fields import WAIT_CAP, bounded, decode, expect, field, seconds
from .records import iso_epoch
from .rules import rule_fields, rule_matches, signed_fields, subject_of
from .signed import signing
# Trip-wire: MCP carries no caller identity, so an agent may name itself anew for each request; AGENT_CAP only spreads the queue, TOTAL_CAP is the bound.
AGENT_CAP = 3
TOTAL_CAP = 20
WHY_LIMIT = 1000
RECENT_DAYS = 7
RULE_FIELDS = ("kind", "tool", "pattern", "scope", "expires")
REQUEST_STATE = ("id", "status", "rule", "reason", "decided")
def request_state(record: dict, now: float) -> dict:
return {**{key: record[key] for key in REQUEST_STATE}, "status": request_status(record, now)}
def created_of(record: dict) -> float:
return record["created_ts"] if "created_ts" in record else iso_epoch(record["created"]) or 0.0
class Requesting:
def open_requests(self, now: float) -> list[dict]:
return [record for record in self.rule_requests.pending() if request_status(record, now) == "pending"]
def rule_request(self, arguments: dict) -> dict:
expect(arguments, (*RULE_FIELDS, "agent", "why"))
now = time.time()
fields = rule_fields(arguments, now)
if fields["kind"] == "decision" and fields["scope"] != "*":
raise ToolError("a decision rule takes scope *, as the rules form does")
name = field(arguments, "agent").strip()
why = bounded(arguments, "why", WHY_LIMIT).strip()
if not why:
raise ToolError("why must name the purpose of the requested rule")
def capped(records: list[dict]) -> None:
pending = [record for record in records if request_status(record, now) == "pending"]
if len(pending) >= TOTAL_CAP:
raise ToolError(f"{TOTAL_CAP} rule requests wait for the operator; ask again once some are answered")
if sum(1 for record in pending if record["agent"] == name) >= AGENT_CAP:
raise ToolError(f"{name} already has {AGENT_CAP} pending rule requests; wait for their answer with rule_request_wait")
record = self.rule_requests.add_checked({**fields, "agent": name, "why": why, "status": "pending", "rule": None, "reason": "", "channel": "", "proof": None, "decided": None, "created_ts": now}, capped)
self.log.add({"kind": "rule", "title": f"Regelwunsch von {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}})
return {"id": record["id"], "status": record["status"], "url": self.url()}
def rule_request_wait(self, arguments: dict) -> dict:
expect(arguments, ("id", "timeout_seconds"))
request_id = field(arguments, "id")
record = self.rule_requests.wait(request_id, seconds(arguments, "timeout_seconds", WAIT_CAP))
if record is None:
raise ToolError(f"no rule request {request_id}")
return request_state(record, time.time())
def dry_run(self, record: dict, now: float) -> dict:
"""Returns:
How many of the asks, reviews or decisions of the last
RECENT_DAYS the requested rule would have approved, matched by
the same code that applies a stored rule.
"""
kind_name = record["kind"]
payload = signing.rule_payload(record["id"], [kind_name], record["tool"], record["pattern"], record["scope"], int(record["expires"]))
rule = {**{key: record[key] for key in RULE_FIELDS}, "enabled": True, "proof": {"payload": payload, "assertion": None}}
ledger = {"ask": self.asks, "review": self.reviews, "decision": self.decisions}[kind_name]
since = now - RECENT_DAYS * 86400
subjects = [subject_of(kind_name, entry) for entry in ledger.listed() if created_of(entry) >= since]
return {"matched": sum(rule_matches(rule, kind_name, subject, now) for subject in subjects), "total": len(subjects), "days": RECENT_DAYS}
def request_items(self, base: dict, now: float) -> list[dict]:
items = []
for record in self.open_requests(now):
rule = {key: record[key] for key in RULE_FIELDS}
fields = {"title": f"Regel: {request_words(record)}", "agent": record["agent"], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True}
items.append({**base, "kind": "rule", "id": record["id"], "ts": record["created_ts"], **fields})
return items
def pending_request(self, request_id: str) -> dict:
record = self.rule_requests.get(request_id)
if record is None:
raise Missing(request_id)
state = request_state(record, time.time())
if state["status"] != "pending":
raise Conflict(state)
if self.batches.holds("rule", request_id):
raise Conflict({"id": request_id, "status": "held"})
return record
def sign_request(self, request_id: str) -> dict:
record = self.pending_request(request_id)
return {**self.rule_offer(rule_fields(record, time.time())), "sign": True}
def accept_request(self, request_id: str, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("rule", "assertion"))
record = self.pending_request(request_id)
now = time.time()
rule = payload["rule"]
fields = signed_fields(rule, now)
if [fields[key] for key in RULE_FIELDS] != [record[key] for key in RULE_FIELDS]:
raise ToolError("the signed rule must be exactly the requested one")
if self.rules.get(rule["id"]) is not None:
raise Conflict({"id": rule["id"], "status": "exists"})
proof = self.proven_rule(rule, payload["assertion"])
decided, changed = self.rule_requests.decide(request_id, {"status": "accepted", "rule": rule["id"]})
if not changed:
raise Conflict(request_state(decided, now))
self.store_rule(rule, fields, proof, True, now)
self.log.add({"kind": "rule", "title": f"Regelwunsch angenommen: {request_words(record)}", "text": f"von {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}})
return request_state(decided, now)
+1 -1
View File
@@ -66,7 +66,7 @@ class Reviewing:
page = self.read_page(field(arguments, "page"))
extra = {"notes": notes, "gate": arguments["gate"], "score": score}
review = self.reviews.submit({**fields, "title": fields["title"].strip(), **extra}, page, review_files(page))
self.automate("review", review, {"item": review_signed(review), "gate": review["gate"]})
self.automate("review", review)
return {"id": review["id"], "status": review["status"], "url": self.url()}
def review_proof(self, review_id: str) -> dict:
+51 -23
View File
@@ -5,18 +5,26 @@ import time
from .errors import Conflict, Missing, ToolError
from .fields import COMMAND_LIMIT, PATH_LIMIT, bounded, decode, expect
from .signed import signing
from .signed import ask_signed, passkey, review_signed, signing, verifier
RULE_KINDS = ("ask", "review", "decision")
def rule_matches(rule: dict, kind_name: str, subject: dict, now: float) -> bool:
if not rule["enabled"] or rule["expires"] <= now or rule["kind"] != kind_name:
proof = rule.get("proof")
if not rule["enabled"] or rule["expires"] <= now or rule["kind"] != kind_name or proof is None:
return False
if kind_name == "decision":
return subject["recommended"] != "" and fnmatch.fnmatchcase(subject["agent"], rule["pattern"])
proof = rule.get("proof")
return proof is not None and signing.rule_allows(proof["payload"], subject["item"], subject["gate"], now)
return signing.rule_allows(proof["payload"], subject["item"], subject["gate"], now)
def subject_of(kind_name: str, record: dict) -> dict:
if kind_name == "ask":
return {"item": ask_signed(record), "gate": ""}
if kind_name == "review":
return {"item": review_signed(record), "gate": record.get("gate", "")}
return {"recommended": record["recommended"], "agent": record["agent"]}
def rule_fields(raw: dict, now: float) -> dict:
@@ -34,38 +42,58 @@ def rule_fields(raw: dict, now: float) -> dict:
return {"kind": kind_name, "tool": tool_name, "pattern": pattern, "scope": scope, "expires": int(expires)}
def signed_fields(rule: object, now: float) -> dict:
try:
signing.checked_rule(rule, now)
except signing.Refused as error:
raise ToolError(f"rule: {error}") from None
if len(rule["kinds"]) != 1:
raise ToolError("a rule names exactly one kind")
fields = rule_fields({**{name: rule[name] for name in ("tool", "pattern", "scope", "expires")}, "kind": rule["kinds"][0]}, now)
if fields["tool"] != rule["tool"]:
raise ToolError("tool must not carry surrounding blanks")
return fields
class Rules:
def rule_offer(self, fields: dict) -> dict:
rule = signing.rule_payload(self.rules.fresh(), [fields["kind"]], fields["tool"], fields["pattern"], fields["scope"], fields["expires"])
return {"payload": rule, "challenge": signing.challenge(rule), "credentials": [key["id"] for key in self.keys()]}
def proven_rule(self, rule: dict, assertion: object) -> dict:
"""Args:
rule: the rule payload, already checked by signed_fields.
assertion: the passkey assertion over its challenge.
Returns:
The proof to store. The hub verifies it itself, since it settles
matching items by the rule.
"""
try:
passkey.verify_assertion(assertion, rule, verifier.pinned(self.approvers), signing.origin_of(self.public_port))
except signing.Refused as error:
raise ToolError(f"nicht signiert: {error}") from None
return {"payload": rule, "assertion": assertion}
def store_rule(self, rule: dict, fields: dict, proof: dict, enabled: bool, now: float) -> dict:
return self.rules.insert(rule["id"], {**fields, "expires": float(fields["expires"]), "enabled": enabled, "created_ts": now, "proof": proof})
def sign_rule(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("kind", "tool", "pattern", "scope", "expires"))
fields = rule_fields(payload, time.time())
rule = signing.rule_payload(self.rules.fresh(), [fields["kind"]], fields["tool"], fields["pattern"], fields["scope"], fields["expires"])
credentials = [key["id"] for key in self.keys()]
return {"payload": rule, "challenge": signing.challenge(rule), "sign": fields["kind"] != "decision", "credentials": credentials}
return {**self.rule_offer(rule_fields(payload, time.time())), "sign": True}
def add_rule(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("rule", "assertion", "enabled"))
rule, assertion = payload["rule"], payload["assertion"]
rule = payload["rule"]
now = time.time()
try:
signing.checked_rule(rule, now)
except signing.Refused as error:
raise ToolError(f"rule: {error}") from None
if len(rule["kinds"]) != 1:
raise ToolError("a rule names exactly one kind")
fields = rule_fields({**{name: rule[name] for name in ("tool", "pattern", "scope", "expires")}, "kind": rule["kinds"][0]}, now)
if fields["tool"] != rule["tool"]:
raise ToolError("tool must not carry surrounding blanks")
signed = fields["kind"] != "decision"
if not (isinstance(assertion, dict) if signed else assertion is None):
raise ToolError("an ask or review rule carries the passkey assertion; a decision rule carries null")
fields = signed_fields(rule, now)
if not isinstance(payload["enabled"], bool):
raise ToolError("enabled must be true or false")
if self.rules.get(rule["id"]) is not None:
raise Conflict({"id": rule["id"], "status": "exists"})
proof = {"payload": rule, "assertion": assertion} if signed else None
return self.rules.insert(rule["id"], {**fields, "expires": float(fields["expires"]), "enabled": payload["enabled"], "created_ts": now, "proof": proof})
return self.store_rule(rule, fields, self.proven_rule(rule, payload["assertion"]), payload["enabled"], now)
def rule_action(self, rule_id: str, verb: str) -> dict:
if verb == "delete":
+34 -3
View File
@@ -1,11 +1,38 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
signing = importlib.util.module_from_spec(SIGNING_SPEC)
SIGNING_SPEC.loader.exec_module(signing)
CLIENT = Path(__file__).resolve().parents[1] / "client"
def client_module(name: str, uses: dict[str, ModuleType]) -> ModuleType:
"""Args:
name: a module of orchestrator/client, loaded under its own
kvb_orchestrator_<name> name.
uses: the client modules it imports by their bare names; they are
visible under those names only while it loads.
"""
spec = importlib.util.spec_from_file_location(f"kvb_orchestrator_{name}", CLIENT / f"{name}.py")
module = importlib.util.module_from_spec(spec)
before = {key: sys.modules.get(key) for key in uses}
sys.modules.update(uses)
try:
spec.loader.exec_module(module)
finally:
for key, old in before.items():
if old is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = old
return module
signing = client_module("signing", {})
passkey = client_module("passkey", {"signing": signing})
verifier = client_module("verifier", {"signing": signing, "passkey": passkey})
def ask_signed(ask: dict) -> dict:
@@ -18,3 +45,7 @@ def review_signed(review: dict) -> dict:
def operator_signed(row: dict) -> dict:
return signing.operator_item(row["task"], row["command"], row["directory"])
def request_signed(request: dict) -> dict:
return signing.rule_request_item(request["id"], request["kind"], request["tool"], request["pattern"], request["scope"], int(request["expires"]))
+4 -2
View File
@@ -46,8 +46,10 @@
input off.
--whisper-model: the ggml model whisper.cpp loads.
--scratch: writable directory the engines use as HOME and TMPDIR.
--approvers: the pinned passkeys, mounted read-only; the hub only lists
their ids and algorithms for the page and never verifies or pins.
--approvers: the pinned passkeys, mounted read-only; the hub lists their
ids and algorithms for the page, never pins, and verifies a signature
only for the denials and rules it acts on itself; every other approval
its executor verifies.
"""
from __future__ import annotations
+8
View File
@@ -403,6 +403,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
.graph3d .scene-container { position: relative; }
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
{% include "style-hierarchy.css" %}
{% include "style-inbox.css" %}
</style>
</head>
<body>
@@ -472,6 +474,12 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-inbox.js" %}
{% include "page-console.js" %}
{% include "page-deny.js" %}
{% include "page-rule-request.js" %}
{% include "page-review.js" %}
{% include "page-filters.js" %}
+99
View File
@@ -0,0 +1,99 @@
const consoles = new Map();
const FOLLOW_SLACK = 12;
const CONSOLE_POLL = 2000;
const jobOpen = (job) => job.status === "queued" || job.status === "running";
const withoutOutput = (item) => (item.job ? {...item, job: {...item.job, output: null}} : item);
const consoleSizes = new ResizeObserver((entries) => {
for (const entry of entries) {
const view = consoles.get(entry.target.dataset.job);
if (view && view.follow) {
view.out.scrollTop = view.out.scrollHeight;
}
}
});
function lasting(seconds) {
const total = Math.max(0, Math.round(seconds));
if (total < 60) {
return `${total} s`;
}
if (total < 3600) {
return `${Math.floor(total / 60)} min ${total % 60} s`;
}
return `${Math.floor(total / 3600)} h ${Math.floor((total % 3600) / 60)} min`;
}
function consoleWords(job) {
if (job.status === "queued") {
return `wartet auf den Runner seit ${lasting(nowSeconds() - job.created_ts)}`;
}
if (job.status === "running") {
return `läuft seit ${lasting(nowSeconds() - job.started)}`;
}
if (job.status === "refused") {
return "der Runner hat die Signatur nicht angenommen";
}
return `Exit-Code ${job.code} nach ${lasting(job.finished - job.started)}`;
}
function paintConsole(view, job) {
const text = job.output || (jobOpen(job) ? "Noch keine Ausgabe." : "Keine Ausgabe.");
if (view.out.textContent !== text) {
view.out.textContent = text;
}
view.box.dataset.status = job.status;
view.line.replaceChildren(status(job.status), h("span", {}, consoleWords(job)));
}
function consoleOf(job) {
let view = consoles.get(job.id);
if (!view) {
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Ausgabe des Befehls", "data-job": job.id});
const line = h("div", {class: "console-status"});
view = {box: h("div", {class: "console", "data-job": job.id}, line, out), out, line, follow: true, top: 0};
out.addEventListener("scroll", () => {
view.top = out.scrollTop;
view.follow = out.scrollHeight - out.scrollTop - out.clientHeight <= FOLLOW_SLACK;
});
consoles.set(job.id, view);
consoleSizes.observe(out);
}
paintConsole(view, job);
return view.box;
}
function settleConsoles(items) {
const shown = new Set(items.filter((item) => item.job).map((item) => item.job.id));
for (const [id, view] of consoles) {
if (!shown.has(id)) {
consoleSizes.unobserve(view.out);
consoles.delete(id);
} else if (view.box.isConnected) {
view.out.scrollTop = view.follow ? view.out.scrollHeight : view.top;
}
}
}
function repaintConsoles(items) {
for (const item of items) {
const view = item.job ? consoles.get(item.job.id) : null;
if (view) {
paintConsole(view, item.job);
}
}
settleConsoles(items);
}
async function dismissJob(job) {
const result = await sendJson("/api/inbox/dismiss", {id: job.id});
if (!result.ok) {
toast("Schließen nicht möglich", fail(result), false);
}
await load();
}
window.setInterval(() => {
if (ui.screen === "inbox" && state.inbox.some((item) => item.held || (item.job && jobOpen(item.job)))) {
refresh();
}
}, CONSOLE_POLL);
+1 -1
View File
@@ -9,7 +9,7 @@
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator", rule: "Regelwunsch"};
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
const UPLOAD_LIMIT = 50 * 1024 * 1024;
+40
View File
@@ -0,0 +1,40 @@
const DENY_CHANNELS = [["post", true, "sobald sein Zug endet"], ["call", true, "sofort, weckt ihn"], ["newsletter", false, "sobald ihr Zug endet"], ["broadcast", false, "sofort"], ["pan-pan", false, "sofort, dringend"], ["security", false, "sofort, als Sicherheitsmeldung"]];
const DENY_REASON = 2000;
const requesterOf = (item) => item.agent || "den anfragenden Agenten";
function denyNow(item, channel, reason) {
return submit([{...entryOf(item, "reject", ""), channel, reason}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
}
function denySheet(item) {
const name = `deny-${keyOf(item)}`;
const choices = DENY_CHANNELS.map(([id, direct, hint]) => h("label", {class: "deny-channel"},
h("input", {type: "radio", name, value: id, checked: id === "post"}),
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `an ${direct ? requesterOf(item) : "alle"}, ${hint}`))));
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "z. B. erst das Gate grün machen"});
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Ablehnen");
confirm.addEventListener("click", () => {
const picked = choices.map((label) => label.querySelector("input")).find((input) => input.checked);
closeSheet();
denyNow(item, picked.value, reason.value);
});
openSheet("Ablehnen", [
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? person(item.agent) : null),
h("p", {class: "text"}, item.title),
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Kanal"), choices),
h("label", {class: "deny-reason"}, "Grund (optional, der Agent liest ihn mit)", reason),
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal und Grund; danach bleiben 3 s zum Rückgängigmachen.",
" Mayday bleibt dem Operator vorbehalten."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
reason.focus();
}
function denySplit(item) {
const main = iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => denyNow(item, "post", ""), `Ablehnen: per Post an ${requesterOf(item)}, 3 s rückgängig`);
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Ablehnen über …", "Ablehnen über einen anderen Kanal oder mit Grund")}, caret());
more.addEventListener("click", (event) => {
event.stopPropagation();
denySheet(item);
});
return h("div", {class: "deny-split", role: "group", "aria-label": "Ablehnen"}, main, more);
}
+1 -1
View File
@@ -56,7 +56,7 @@
const visibleInbox = () => state.inbox.filter((item) => ui.snoozed || !item.until);
const waitingCount = () => state.inbox.filter((item) => !item.until).length;
const isGreen = (item) => item.green && !item.held && !item.until && item.kind !== "operator";
const selectable = (item) => !item.held && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
const selectable = (item) => !item.held && item.kind !== "rule" && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
const entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""});
function tick() {
+22 -4
View File
@@ -49,6 +49,9 @@
}
function approveOne(item) {
if (item.kind === "rule") {
return acceptRule(item);
}
if (item.kind === "decision") {
if (!item.recommended) {
openItem(item);
@@ -116,12 +119,22 @@
return [h("span", {class: "muted"}, "wird wirksam …")];
}
const buttons = [];
if (item.kind === "review" || item.kind === "ask") {
if (item.kind === "review") {
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
} else if (item.kind === "ask") {
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)));
} else if (item.kind === "decision" && item.recommended) {
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
} else if (item.kind === "operator" && item.runnable) {
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
} else if (item.kind === "rule") {
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Annehmen", "Annehmen: der Passkey signiert genau diese Regel, dann gilt sie"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Annehmen"));
}
if (item.deniable) {
buttons.push(denySplit(item));
}
if (item.job && !jobOpen(item.job)) {
buttons.push(iconBtn("bi-x-circle", "Schließen", "btn small ghost", () => dismissJob(item.job), "Schließen: Konsole ausblenden, eine erledigte Zeile verschwindet mit ihr"));
}
return buttons;
}
@@ -144,7 +157,9 @@
item.agent ? h("div", {class: "muted"}, "von ", person(item.agent)) : null,
item.kind === "review" ? h("div", {class: "muted"}, item.detail, item.score ? ` · ${item.score}` : "") : null,
item.kind === "operator" && item.title !== item.command ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail !== "-" ? ` · ${item.detail}` : "") : null,
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail && item.detail !== "-" ? ` · ${item.detail}` : "") : null,
item.job ? consoleOf(item.job) : null,
item.kind === "rule" ? ruleRequestBody(item) : null,
item.kind === "ask" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`) : null,
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
@@ -176,8 +191,9 @@
ui.selected.delete(key);
}
}
const sig = JSON.stringify([items, [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]);
const sig = JSON.stringify([items.map(withoutOutput), [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]);
if (sig === inboxSig) {
repaintConsoles(items);
return;
}
inboxSig = sig;
@@ -194,8 +210,9 @@
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
h("div", {class: "inbox-actions"}, choose, all),
onLocalhost() ? null : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
onLocalhost() ? "" : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
settleConsoles(items);
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
if (current && document.activeElement === document.body) {
current.scrollIntoView({block: "nearest"});
@@ -214,6 +231,7 @@
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? face(item.agent, false) : null, item.agent ? person(item.agent) : null, when(item.ts)),
h("p", {class: "text"}, item.title),
item.command ? h("div", {class: "command"}, h("code", {}, item.command), copyButton(item.command)) : null,
item.kind === "rule" ? ruleRequestBody(item) : null,
meta([["Verzeichnis", item.directory], ["Warnungen", item.warnings.join(", ")], ["Id", item.id]]),
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => {
@@ -0,0 +1,28 @@
const COUNTED = {ask: "Asks", review: "Reviews", decision: "Entscheidungen"};
function ruleRequestBody(item) {
const rule = item.rule;
const dry = item.dry_run;
const pairs = [["Art", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Werkzeug (Glob)", rule.tool] : null, ["Muster (Glob)", rule.pattern], rule.kind === "decision" ? null : ["Verzeichnis (Glob)", rule.scope], ["Läuft ab am", absolute.format(stampOf(rule.expires))]];
return h("div", {class: "rule-request"},
item.detail ? h("p", {class: "text"}, item.detail) : null,
meta(pairs.filter(Boolean)),
h("p", {class: "muted dry-run-count"}, "Trockenlauf: Die Regel hätte ", h("strong", {}, `${dry.matched} von ${dry.total}`), ` ${COUNTED[rule.kind]} der letzten ${dry.days} Tage freigegeben.`),
h("p", {class: "muted"}, RULE_HINTS[rule.kind]));
}
async function acceptRule(item) {
const label = `Regel: ${short(item.title)}`;
ui.busy = true;
const prepared = await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/sign`, {});
const signed = prepared.ok && prepared.data ? await assertion(prepared.data, label) : null;
if (!prepared.ok || !prepared.data) {
toast(`${label}: nicht möglich`, fail(prepared), false);
}
const result = signed ? await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/accept`, {rule: prepared.data.payload, assertion: signed}) : null;
ui.busy = false;
if (result) {
toast(result.ok ? `Angelegt: ${short(item.title)}` : `${label}: nicht angelegt`, result.ok ? [] : fail(result), result.ok);
}
await load();
}
+10 -7
View File
@@ -1,5 +1,12 @@
const RULE_KINDS = {ask: "Ask: Werkzeug + Befehlsmuster", review: "Review: Branch-Muster + Gate grün", decision: "Entscheidung: Empfehlung nehmen"};
const RULE_HINTS = {
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option. Der Passkey signiert die Regel einmal; die Entscheidungen selbst bleiben unsigniert.",
};
function rulesScreen() {
const kind = h("select", {class: "field", "aria-label": "Art"}, h("option", {value: "ask"}, "Ask: Werkzeug + Befehlsmuster"), h("option", {value: "review"}, "Review: Branch-Muster + Gate grün"), h("option", {value: "decision"}, "Entscheidung: Empfehlung nehmen"));
const kind = h("select", {class: "field", "aria-label": "Art"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
@@ -13,11 +20,7 @@
toolField.hidden = kind.value !== "ask";
scopeField.hidden = kind.value === "decision";
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (Agent-Muster)"}[kind.value];
hint.textContent = {
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option; Entscheidungen bleiben unsigniert.",
}[kind.value];
hint.textContent = RULE_HINTS[kind.value];
};
kind.addEventListener("change", sync);
sync();
@@ -58,7 +61,7 @@
rule.tool ? h("code", {}, rule.tool) : null,
h("code", {}, rule.pattern),
rule.kind === "decision" ? null : h("code", {title: "Verzeichnis"}, rule.scope || "–"),
rule.kind === "decision" ? null : pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
rule.expires <= now ? pill("abgelaufen", "bad") : h("span", {class: "muted"}, `bis ${absolute.format(stampOf(rule.expires))}`),
rule.enabled ? pill("aktiv", "ok") : pill("aus", "neutral"),
h("span", {class: "spacer"}),
+20
View File
@@ -0,0 +1,20 @@
.console { display: grid; margin-top: var(--sp-2); overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); }
.console[data-status="failed"], .console[data-status="refused"] { border-color: color-mix(in srgb, var(--bad) 50%, var(--line)); }
.console-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: 5px 10px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.console-out { min-height: 3.2em; max-height: min(40vh, 320px); margin: 0; padding: 8px 10px; overflow: auto; color: var(--text); font-family: var(--mono); font-size: var(--fs-sm); line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; overscroll-behavior: contain; }
.console-out:focus-visible { outline-offset: -2px; }
.deny-split { display: inline-flex; }
.deny-split > .btn:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.deny-split > .btn + .btn { min-width: 22px; margin-left: -1px; padding-right: 5px; padding-left: 5px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.deny-channels { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: var(--sp-2); min-width: 0; margin: 0; padding: 0; border: 0; }
.deny-channels legend { margin-bottom: var(--sp-2); padding: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.deny-channel { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); cursor: pointer; }
.deny-channel:has(input:checked) { border-color: var(--bad); box-shadow: inset 3px 0 0 var(--bad); }
.deny-channel input { margin-top: 3px; accent-color: var(--bad); }
.deny-channel span { display: grid; gap: 1px; }
.deny-channel small { color: var(--muted); font-size: var(--fs-xs); }
.deny-reason { display: grid; gap: var(--sp-1); color: var(--muted); font-size: var(--fs-sm); }
.rule-request { display: grid; gap: var(--sp-1); margin-top: var(--sp-2); padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); }
.rule-request p { margin: 0; }
.rule-request .meta { margin: 0; }
.dry-run-count strong { color: var(--text); font-variant-numeric: tabular-nums; }