Merge feature/workbench-w1-operator-tasks into unify/skills-20261112

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:23:36 +00:00
commit eb4a335984
34 files changed
+2449 -111

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ RUN chmod 0755 /opt/voices /opt/whisper
COPY server.py /app/server.py
COPY kvb_orchestrator /app/kvb_orchestrator
COPY client/signing.py client/passkey.py client/verifier.py /app/client/
COPY client/hub_limits.py client/signing.py client/passkey.py client/verifier.py /app/client/
COPY templates /app/templates
COPY vendor /app/vendor
+58 -10
View File
@@ -9,10 +9,21 @@
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.
request --port <port> --title <subject> --page <file> --notes <file>
--gate <pass|fail> --score <line> --agent <name> [--rules <dir>]:
run in the worktree; asks the operator to approve the staged tree,
with the repository, worktree, branch and tree as git prints them
here, the notes file's text (refused past NOTES_LIMIT characters),
and the diff of the tree git write-tree prints against HEAD
(staged_diff.tree_diff, content withheld per the MEDUSA rules under
<dir>, by default those of the pipx install); without the diff when
the body would pass DIFF_BODY_LIMIT.
Returns:
pin: exit code 0 and the pinned credential on stdout; 1 and the reason
on stderr when the blob is no valid registration.
request: exit code 0 and the hub's {"id", "status", "url"} on stdout;
1 and the reason on stderr.
verify: exit code 0 when a pinned key signed this exact commit; 1 and
the reason on stderr otherwise. Commit only on 0.
"""
@@ -21,28 +32,24 @@ from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
import staged_diff
from hub_api import GUARD, SLACK, base_url, load
from hub_limits import DIFF_BODY_LIMIT, NOTES_LIMIT
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()
return staged_diff.git(cwd, args, None).decode("utf-8", "surrogateescape").strip()
except staged_diff.DiffError as error:
raise Refused(str(error)) from error
def staged(cwd: Path, review_id: str) -> dict:
@@ -72,6 +79,38 @@ def verify_commit(args: argparse.Namespace) -> str:
return f"verified: the operator signed review {args.review}, tree {item['tree']} on {item['branch']} in {item['worktree']}"
def review_body(arguments: dict) -> bytes:
call = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "review_request", "arguments": arguments}}
return json.dumps(call, ensure_ascii=False).encode("utf-8")
def request_review(args: argparse.Namespace) -> str:
cwd = Path.cwd()
item = staged(cwd, "0" * 16)
try:
notes = args.notes.read_text(encoding="utf-8")
diff = staged_diff.tree_diff(cwd, item["tree"], args.rules or staged_diff.rules_root())
except (OSError, ValueError, staged_diff.DiffError) as error:
raise Refused(f"the review cannot be prepared: {error}") from error
if len(notes) > NOTES_LIMIT:
raise Refused(f"the notes hold {len(notes)} characters, the hub takes at most {NOTES_LIMIT}")
arguments = {key: item[key] for key in ("repo", "worktree", "branch", "tree")}
arguments.update(title=args.title, page=str(args.page), notes=notes, gate=args.gate, score=args.score, agent=args.agent)
body = review_body({**arguments, "diff": diff})
if len(body) > DIFF_BODY_LIMIT:
print(f"approval: the review goes without its diff, its {len(body)} bytes pass the hub's {DIFF_BODY_LIMIT}", file=sys.stderr)
body = review_body(arguments)
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
try:
answer = load(urllib.request.Request(f"{base_url(args.port)}/mcp", data=body, headers=headers, method="POST"), SLACK)
except (OSError, ValueError) as error:
raise Refused(f"the Agent Orchestrator gave no answer: {error}") from error
result = answer.get("result") if isinstance(answer, dict) else None
if not isinstance(result, dict) or result.get("isError") or not isinstance(result.get("structuredContent"), dict):
raise Refused(f"the Agent Orchestrator refused the review: {json.dumps(answer)[:500]}")
return json.dumps(result["structuredContent"])
def pin_credential(args: argparse.Namespace) -> str:
try:
credential = json.loads(b64decode(args.credential, "credential"))
@@ -100,9 +139,18 @@ def main() -> int:
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)
requesting = commands.add_parser("request")
requesting.add_argument("--port", type=int, required=True)
for name in ("--title", "--score", "--agent"):
requesting.add_argument(name, required=True)
requesting.add_argument("--page", type=Path, required=True)
requesting.add_argument("--notes", type=Path, required=True)
requesting.add_argument("--gate", choices=("pass", "fail"), required=True)
requesting.add_argument("--rules", type=Path)
args = parser.parse_args()
run = {"pin": pin_credential, "verify": verify_commit, "request": request_review}[args.command]
try:
print(pin_credential(args) if args.command == "pin" else verify_commit(args))
print(run(args))
except (Refused, OSError) as error:
print(f"refused: {error}", file=sys.stderr)
return 1
+20
View File
@@ -0,0 +1,20 @@
"""Returns:
DIFF_LIMIT: bytes of JSON-encoded patch text one review diff carries, in
the client that builds it and the hub that takes it.
DIFF_BODY_LIMIT: bytes of request body the hub reads for an ask or a
review request; the client sends a larger one without its diff.
FILE_CAP: files one review diff lists; the client builds no diff of more.
PATH_LIMIT: characters of a path the hub takes, a diff path included;
the client builds no diff with a longer one.
NOTES_LIMIT: characters of review notes the hub takes; the client refuses
longer notes instead of cutting them.
WITHHELD: the note of a diff entry that ships without its patch because
its content may hold a secret.
"""
DIFF_LIMIT = 2 << 20
DIFF_BODY_LIMIT = 4 << 20
FILE_CAP = 5000
PATH_LIMIT = 4096
NOTES_LIMIT = 100_000
WITHHELD = "content withheld"
+78 -8
View File
@@ -16,7 +16,14 @@ Returns:
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.
The exit code is 0. An ask for a Bash git commit carries the diff
staged_diff.py builds of the tree its directory stages (of the
working tree against HEAD with -a or --all), content withheld per
the MEDUSA rules of the pipx install; none when it cannot be built
or the body would pass DIFF_BODY_LIMIT. An allow of such an ask
turns into a deny with CHANGED when what the commit records
(staged_diff.binding) differs right before answering from the
preview's, and with UNBOUND when either cannot be read.
"""
from __future__ import annotations
@@ -30,17 +37,22 @@ import urllib.request
from pathlib import Path
import hub_api
import hub_limits
import signing
import staged_diff
import verifier
POLL = 25.0
NAME_LIMIT = 200
COMMAND_LIMIT = 4000
PATH_LIMIT = 4096
PATH_LIMIT = hub_limits.PATH_LIMIT
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."
CHANGED = "staged tree changed since the preview"
UNBOUND = "the staged tree cannot be read, so the allow is not bound to it"
REASON_LIMIT = 2000
CONTROL = re.compile(r"[\x00-\x1f\x7f]")
FAILURES = (OSError, ValueError, staged_diff.DiffError)
def summary(tool_input: object) -> str:
@@ -61,10 +73,67 @@ def facts(hook: dict) -> dict:
}
def create(base: str, ask: dict, timeout: float) -> str:
payload = {**ask, "timeout": timeout}
def commit_target(ask: dict) -> tuple[Path, bool] | None:
return staged_diff.commit_of(ask["command"], ask["cwd"]) if ask["tool"] == "Bash" and ask["cwd"] else None
def commit_diff(ask: dict, rules: Path | None) -> tuple[str | None, dict]:
"""Returns:
For a commit ask, what the commit records (staged_diff.binding, None
when it cannot be read) and {"diff": the preview of exactly that},
{} when it cannot be built; None and {} for any other ask.
"""
target = commit_target(ask)
if target is None:
return None, {}
try:
pinned = staged_diff.binding(*target)
except FAILURES as error:
print(f"kvb-orchestrator: the ask goes without its diff and an allow of it turns into a deny: {error}", file=sys.stderr)
return None, {}
try:
return pinned, {"diff": staged_diff.bound_diff(*target, pinned, rules)}
except FAILURES as error:
print(f"kvb-orchestrator: the ask goes without its diff: {error}", file=sys.stderr)
return pinned, {}
def clean(text: str) -> str:
return CONTROL.sub("?", text.strip())[:REASON_LIMIT]
def bound(chosen: dict | None, ask: dict, pinned: str | None) -> dict | None:
"""Args:
pinned: what the commit recorded when the ask was made, None when
that could not be read.
Returns:
chosen, or for an allow of a commit ask a deny with CHANGED when
what it records now differs from pinned, with UNBOUND and the error
when either cannot be read.
"""
if chosen != {"behavior": "allow"} or commit_target(ask) is None:
return chosen
try:
now = staged_diff.binding(*commit_target(ask))
except FAILURES as error:
return {"behavior": "deny", "message": f"{UNBOUND}: {clean(str(error))}"}
if pinned is None:
return {"behavior": "deny", "message": UNBOUND}
return chosen if now == pinned else {"behavior": "deny", "message": CHANGED}
def encoded(payload: dict) -> bytes:
return json.dumps(payload).encode()
def create(base: str, ask: dict, timeout: float, shown: dict) -> str:
data = encoded({**ask, "timeout": timeout, **shown})
if len(data) > hub_limits.DIFF_BODY_LIMIT:
print(f"kvb-orchestrator: the ask goes without its diff, its {len(data)} bytes pass the hub's {hub_limits.DIFF_BODY_LIMIT}", file=sys.stderr)
data = encoded({**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")
request = urllib.request.Request(f"{base}/api/asks", data=data, 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")
@@ -87,7 +156,7 @@ def denied(answer: dict, item: dict, trust: verifier.Trust) -> str:
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]}"
return f"{DENIED} Reason: {clean(reason)}"
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
@@ -120,13 +189,14 @@ def main() -> int:
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)
pinned, shown = commit_diff(ask, staged_diff.rules_root())
ask_id = create(base, ask, args.timeout, shown)
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)
chosen = bound(decision(answer, signing.ask_item(ask_id, ask["tool"], ask["command"], ask["cwd"], ask["session"]), trust), ask, pinned)
if chosen is not None:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": chosen}}))
return 0
+167
View File
@@ -0,0 +1,167 @@
"""Args:
top: the working tree; its .claude folder and that of $CLAUDE_PROJECT_DIR join the merge.
Returns:
denied_roots: FLOOR with every sandbox.filesystem.denyRead path and every
permissions.deny Read(...) rule of the settings Claude Code merges
(managed, user, project), as absolute paths or globs (a **/ also
matching no folder), also resolved
through symlinks; a relative one lies under its scope (managed: its
folder, user: the config dir, project: the project root).
denied: whether a path is, matches or lies under one of those.
Raises:
Unreadable: when a settings file or managed-settings.d exists but cannot be read or parsed.
"""
from __future__ import annotations
import fnmatch
import itertools
import json
import os
import re
import stat
from pathlib import Path
MANAGED = Path("/etc/claude-code")
SETTINGS = ("settings.json", "settings.local.json")
SETTINGS_LIMIT = 1 << 20
GLOB = re.compile(r"[*?\[]")
FLOOR =("~/.ssh", "~/.gnupg", "~/.aws", "~/.kube", "~/.netrc", "~/.git-credentials", "~/.config/gcloud", "~/.docker/config.json", "~/.claude")
READ_RULE = re.compile(r"\s*Read(?:\((.*)\))?\s*")
class Unreadable(Exception):
pass
def project_roots(top: Path) -> list[Path]:
session = os.environ.get("CLAUDE_PROJECT_DIR")
return [top, *([Path(session)] if session else [])]
def settings_files(top: Path) -> list[tuple[Path, Path]]:
"""Returns:
Every settings file Claude Code merges, with the folder its relative
paths start from.
"""
user = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
scoped = [(MANAGED / "managed-settings.json", MANAGED), *((file, MANAGED) for file in managed_parts())]
scoped += [(user / name, user) for name in SETTINGS]
return scoped + [(root / ".claude" / name, root) for root in project_roots(top) for name in SETTINGS]
def managed_parts() -> list[Path]:
folder = MANAGED / "managed-settings.d"
try:
names = sorted(os.listdir(folder))
except FileNotFoundError:
return []
except OSError as error:
raise Unreadable(f"settings folder {folder} unreadable: {type(error).__name__}") from error
return [folder / name for name in names if name.endswith(".json")]
def settings_text(file: Path) -> str | None:
"""Returns:
The text of a settings file, None when it does not exist.
Raises:
Unreadable: when it is no regular file of at most SETTINGS_LIMIT bytes.
OSError, ValueError: when it cannot be opened or is no UTF-8.
"""
try:
fd = os.open(file, os.O_RDONLY | os.O_NONBLOCK)
except FileNotFoundError:
return None
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size > SETTINGS_LIMIT:
raise Unreadable(f"settings {file} is no regular file of at most {SETTINGS_LIMIT} bytes")
with open(fd, "rb", closefd=False) as handle:
return handle.read(SETTINGS_LIMIT + 1).decode("utf-8")
finally:
os.close(fd)
def settings_data(file: Path) -> object:
try:
text = settings_text(file)
return json.loads(text) if text and text.strip() else {}
except (OSError, ValueError) as error:
raise Unreadable(f"settings {file} unreadable: {type(error).__name__}") from error
def strings_at(data: object, keys: tuple[str, ...]) -> list[str]:
for key in keys:
data = data.get(key) if isinstance(data, dict) else None
if data is None:
return []
if not (isinstance(data, list) and all(isinstance(entry, str) for entry in data)):
raise Unreadable(f"{'.'.join(keys)} is no list of strings")
return data
def rule_paths(rules: list[str], scope: Path, folders: list[Path]) -> list[str]:
"""Returns:
The paths the permissions.deny Read rules name, read as Claude Code
reads them: //x absolute, ~/x under home, /x under scope, any other
under scope and every one of folders, a name without / at any depth;
a bare Read denies /.
"""
found = []
for entry in rules:
match = READ_RULE.fullmatch(entry)
if match:
found += rule_path(match.group(1) or "//", scope, folders)
return found
def rule_path(spec: str, scope: Path, folders: list[Path]) -> list[str]:
if spec.startswith("//"):
return ["/" + spec.lstrip("/")]
if spec == "~" or spec.startswith("~/"):
return [os.path.expanduser(spec)]
if spec.startswith("/"):
return [f"{scope}{spec}"]
name = spec.removeprefix("./")
bases = [scope, *folders]
if "/" in name.rstrip("/"):
return [f"{base}/{name}" for base in bases]
return [entry for base in bases for entry in (f"{base}/{name}", f"{base}/*/{name}")]
def denied_roots(top: Path) -> list[str]:
folders = project_roots(top)
entries = [(entry, Path.home()) for entry in FLOOR]
for file, scope in settings_files(top):
data = settings_data(file)
entries += [(entry, scope) for entry in strings_at(data, ("sandbox", "filesystem", "denyRead"))]
entries += [(entry, scope) for entry in rule_paths(strings_at(data, ("permissions", "deny")), scope, folders)]
return [root for entry, scope in entries for root in resolved(entry, scope)]
def resolved(entry: str, base: Path) -> set[str]:
found = set()
for path in zero_folders("/" + os.path.normpath(os.path.join(base, os.path.expanduser(entry))).lstrip("/")):
cut = GLOB.search(path)
head = os.path.dirname(path[: cut.start()]) if cut else path
found |= {path, os.path.realpath(head) + path[len(head) :]}
return found
def zero_folders(path: str) -> set[str]:
"""Returns:
path and every spelling of it with some of its **/ dropped, as a
gitignore ** also matches no folder at all.
"""
parts = path.split("**/")
return {"".join(part + cut for part, cut in zip(parts, cuts, strict=False)) + parts[-1] for cuts in itertools.product(("**/", ""), repeat=len(parts) - 1)}
def denied(path: str, roots: list[str]) -> bool:
if any(path == root or path.startswith(f"{root.rstrip('/')}/") for root in roots if not GLOB.search(root)):
return True
chain = [path, *map(str, Path(path).parents)]
return any(fnmatch.fnmatchcase(link, root) for root in roots if GLOB.search(root) for link in chain)
+583
View File
@@ -0,0 +1,583 @@
"""Args:
cwd: the working directory whose repository the diff is taken of.
everything: False for the staged tree against HEAD (git diff --cached),
True for what git commit -a records (every index path as it lies in
the working tree) against HEAD.
tree: for tree_diff, the tree object (git write-tree) diffed against
HEAD instead of the live index.
rules: the MEDUSA rules directory, None when MEDUSA is not installed.
pinned: for bound_diff, what binding returned for the same cwd and everything.
Returns:
review_diff, tree_diff, bound_diff: the review diff of requirement 003,
one {path, status, mode, additions, deletions, patch, note} per
changed path, sorted by path, status A, M, D or B (binary); path is
the file name with bytes that are no UTF-8 written as backslash
escapes; mode the git file mode, "old => new" when it changed (with
everything, the executable bit on disk). patch holds the unified
hunks; it is empty for a binary file, a gitlink, a side over
BLOB_LIMIT bytes (counted 0/0), a path sandbox_paths denies (its
FLOOR and the settings' deny lists) and every file past DIFF_LIMIT
bytes of JSON-encoded patch text. A file whose old or new content
holds an ARMOUR line or matches a MEDUSA secret rule for its name,
and every file when no secret rule pattern loads, ships no patch,
only its counts and the note WITHHELD. With everything, a working
tree file over BLOB_LIMIT or under a denied path is not read and so
always counts as changed. The diff fails when more than FILE_CAP
paths changed or one is longer than PATH_LIMIT, a settings file
sandbox_paths reads is unreadable, the working tree lies under a
denied path, HEAD exists but its tree cannot be listed, HEAD is
neither a commit nor unborn, or for bound_diff, the working tree
changed while it was read.
binding: what a commit records, for the permission hook to bind an
allow to: the tree git write-tree prints; with everything, joined by
the digest of every index path's mode and content id as it lies in
the working tree, a file not read by its size, inode and times.
commit_of: the directory and -a mode of a single git commit whose
preview matches what it records, None for any other command.
Only git plumbing runs (rev-parse, symbolic-ref, ls-tree, ls-files, cat-file,
write-tree) with core.fsmonitor and lazy fetching off, and the diff itself is computed here:
the permission hook calls this outside the sandbox in a directory the agent
chose, where a planted repository config, filter, diff driver or promisor
remote must start no program, and a working tree path must not lead the read
through a symlinked directory, out of the working tree or into a path the
sandbox denies reading.
"""
from __future__ import annotations
import difflib
import fnmatch
import hashlib
import json
import os
import re
import shlex
import stat
import subprocess
from pathlib import Path
import hub_limits
import sandbox_paths
DIFF_LIMIT = hub_limits.DIFF_LIMIT
FILE_CAP = hub_limits.FILE_CAP
PATH_LIMIT = hub_limits.PATH_LIMIT
WITHHELD = hub_limits.WITHHELD
BLOB_LIMIT = 2 << 20
BINARY_PROBE = 8000
CONTEXT = 3
GIT_TIMEOUT = 30
GITLINK = "160000"
SYMLINK = "120000"
REGULAR = "100644"
EXECUTABLE = "100755"
SECRET_CWE = re.compile(r"CWE-(?:259|321|540|798)\b")
RULES_HOME = Path.home() / ".local" / "pipx" / "venvs" / "medusa-security" / "lib"
RULES_TAIL = "python3*/site-packages/medusa/rules"
CACHE = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "kvb-orchestrator" / "secret-rules.json"
UNREAD = "unread"
SAFE = ("git", "--no-pager", "--no-lazy-fetch", "-c", "core.fsmonitor=false")
COMMIT_LONGS = {
"quiet", "verbose", "file", "author", "date", "message", "reedit-message", "reuse-message", "fixup", "squash", "reset-author", "trailer", "signoff",
"template", "edit", "cleanup", "status", "gpg-sign", "all", "include", "interactive", "patch", "unified", "inter-hunk-context", "only", "no-verify",
"dry-run", "short", "branch", "ahead-behind", "porcelain", "long", "null", "amend", "no-post-rewrite", "untracked-files", "pathspec-from-file",
"pathspec-file-nul", "allow-empty", "allow-empty-message", "verify", "post-rewrite", "no-quiet", "no-verbose", "no-file", "no-author", "no-date",
"no-message", "no-reedit-message", "no-reuse-message", "no-fixup", "no-squash", "no-reset-author", "no-trailer", "no-signoff", "no-template", "no-edit",
"no-cleanup", "no-status", "no-gpg-sign", "no-all", "no-include", "no-interactive", "no-patch", "no-only", "no-dry-run", "no-short", "no-branch",
"no-ahead-behind", "no-porcelain", "no-long", "no-null", "no-amend", "no-untracked-files", "no-pathspec-from-file", "no-pathspec-file-nul", "no-allow-empty",
"no-allow-empty-message",
}
LONG_ARGS = {"message", "file", "reuse-message", "reedit-message", "template", "author", "date", "cleanup", "fixup", "squash", "trailer", "unified", "inter-hunk-context"}
NARROWING = {"only", "include", "interactive", "patch", "pathspec-from-file", "no-all"}
SHORT_ARGS = set("mFcCtuSU")
KEPT_VARS = {"GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_PAGER", "GIT_TERMINAL_PROMPT", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_COMMITTER_DATE"}
ASSIGNMENT = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=.*")
ARMOUR = re.compile(r"-----BEGIN [A-Z0-9 ]+-----")
Patterns = list[re.Pattern]
Rules = list[tuple[tuple[str, ...], Patterns]]
class DiffError(Exception):
pass
def git(cwd: Path, args: list[str], data: bytes | None) -> bytes:
try:
done = subprocess.run([*SAFE, *args], cwd=cwd, input=data, capture_output=True, timeout=GIT_TIMEOUT, check=False, env={**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_NO_LAZY_FETCH": "1"})
except (OSError, subprocess.TimeoutExpired) as error:
raise DiffError(f"git {args[0]} failed to start: {error}") from error
if done.returncode != 0:
raise DiffError(f"git {' '.join(args)} failed: {done.stderr.decode('utf-8', 'replace').strip()}")
return done.stdout
def listed(raw: bytes, object_field: int) -> dict[str, tuple[str, str]]:
found = {}
for record in raw.split(b"\0"):
head, _, path = record.partition(b"\t")
if path:
fields = head.decode("ascii").split()
found[path.decode("utf-8", "surrogateescape")] = (fields[0], fields[object_field])
return found
def blob_sizes(cwd: Path, oids: set[str]) -> dict[str, int]:
order = sorted(oids)
lines = git(cwd, ["cat-file", "--batch-check"], "".join(f"{oid}\n" for oid in order).encode()).decode("ascii").splitlines() if order else []
fields = {oid: line.split() for oid, line in zip(order, lines, strict=True)}
missing = sorted(oid for oid, found in fields.items() if len(found) != 3)
if missing:
raise DiffError(f"objects missing from the repository: {' '.join(missing)}")
return {oid: int(found[2]) for oid, found in fields.items()}
def blobs(cwd: Path, oids: set[str]) -> dict[str, bytes]:
order = sorted(oids)
if not order:
return {}
out = git(cwd, ["cat-file", "--batch"], "".join(f"{oid}\n" for oid in order).encode())
found, at = {}, 0
for oid in order:
end = out.index(b"\n", at)
size = int(out[at:end].split()[2])
found[oid] = out[end + 1 : end + 1 + size]
at = end + 2 + size
return found
def worktree(top: Path, path: str, mode: str, algorithm: str, roots: list[str]) -> tuple[str, str, bytes | None] | None:
"""Args:
top: the working tree, resolved through symlinks.
roots: the paths the sandbox denies reading (sandbox_paths.denied_roots).
Returns:
The mode (for a regular file its executable bit on disk), blob id and
content of an index path as it lies in the working tree; for a file
over BLOB_LIMIT or under one of roots, which is not read, content
None and in place of the blob id UNREAD with its size, inode and
times; None when it is gone, lies behind a symlinked directory or is
neither a regular file nor a symlink.
Raises:
DiffError: for a path with an empty, . or .. component.
"""
parts = path.split("/")
if any(part in ("", ".", "..") for part in parts):
raise DiffError(f"index path {path!r} leaves the working tree")
try:
if sandbox_paths.denied(os.path.join(top, *parts), roots):
return mode, unread(os.lstat(os.path.join(top, *parts))), None
found = on_disk(top, parts)
except OSError:
return None
if found is None:
return None
mode, data, info = found
if data is None:
return mode, unread(info), None
return mode, hashlib.new(algorithm, b"blob %d\0" % len(data) + data).hexdigest(), data
def unread(info: os.stat_result) -> str:
return f"{UNREAD}:{info.st_size}:{info.st_ino}:{info.st_mtime_ns}:{info.st_ctime_ns}"
def on_disk(top: Path, parts: list[str]) -> tuple[str, bytes | None, os.stat_result] | None:
"""Returns:
The mode, content (None past BLOB_LIMIT) and status of top/parts,
every folder from / on opened without following a symlink, None for
an entry that is neither a regular file nor a symlink.
"""
folder = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
try:
for part in [*top.parts[1:], *parts[:-1]]:
inner = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=folder)
os.close(folder)
folder = inner
return leaf(folder, parts[-1])
finally:
os.close(folder)
def leaf(folder: int, name: str) -> tuple[str, bytes | None, os.stat_result] | None:
info = os.stat(name, dir_fd=folder, follow_symlinks=False)
if stat.S_ISLNK(info.st_mode):
return SYMLINK, os.readlink(name, dir_fd=folder).encode("utf-8", "surrogateescape"), info
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=folder)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode):
return None
mode = EXECUTABLE if info.st_mode & stat.S_IXUSR else REGULAR
if info.st_size > BLOB_LIMIT:
return mode, None, info
with open(fd, "rb", closefd=False) as handle:
data = handle.read(BLOB_LIMIT + 1)
return mode, data if len(data) <= BLOB_LIMIT else None, info
finally:
os.close(fd)
def toplevel(cwd: Path) -> Path:
return Path(os.path.realpath(git(cwd, ["rev-parse", "--show-toplevel"], None).decode("utf-8", "surrogateescape").strip()))
def binding(cwd: Path, everything: bool) -> str:
top = toplevel(cwd)
tree = git(top, ["write-tree"], None).decode("ascii").strip()
if not everything:
return tree
new, _ = working_side(top, denied_roots(top))
return f"{tree} {hashlib.sha256(json.dumps(sorted(new.items())).encode('utf-8', 'surrogateescape')).hexdigest()}"
def bound_diff(cwd: Path, everything: bool, pinned: str, rules: Path | None) -> list[dict]:
if not everything:
return tree_diff(cwd, pinned, rules)
diff = review_diff(cwd, True, rules)
if binding(cwd, True) != pinned:
raise DiffError("the working tree changed while its preview was read")
return diff
def sides(cwd: Path, everything: bool, tree: str | None) -> tuple[dict, dict, dict[str, bytes | None]]:
top = toplevel(cwd)
roots = denied_roots(top)
old = head_tree(top)
if tree is not None:
new, contents = listed(git(top, ["ls-tree", "-r", "-z", "--full-tree", tree], None), 2), {}
elif everything:
new, contents = working_side(top, roots)
else:
new, contents = listed(git(top, ["ls-files", "-s", "-z"], None), 1), {}
contents.update(dict.fromkeys(side[path][1] for side in (old, new) for path in side if sandbox_paths.denied(os.path.join(top, path), roots)))
return old, new, contents
def denied_roots(top: Path) -> list[str]:
try:
roots = sandbox_paths.denied_roots(top)
except sandbox_paths.Unreadable as error:
raise DiffError(str(error)) from error
if sandbox_paths.denied(str(top), roots):
raise DiffError(f"the working tree {top} lies under a path the sandbox denies reading")
return roots
def head_tree(top: Path) -> dict[str, tuple[str, str]]:
"""Returns:
The paths of HEAD's tree; none for an unborn HEAD, a symbolic ref
to a branch that does not exist yet.
Raises:
DiffError: when HEAD resolves but its tree cannot be listed, or
neither resolves nor is a symbolic ref.
"""
try:
git(top, ["rev-parse", "-q", "--verify", "HEAD"], None)
except DiffError:
git(top, ["symbolic-ref", "-q", "HEAD"], None)
return {}
return listed(git(top, ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], None), 2)
def working_side(top: Path, roots: list[str]) -> tuple[dict, dict[str, bytes | None]]:
algorithm = git(top, ["rev-parse", "--show-object-format"], None).decode("ascii").strip()
new, contents = {}, {}
for path, (mode, oid) in listed(git(top, ["ls-files", "-s", "-z"], None), 1).items():
found = (mode, oid, None) if mode == GITLINK else worktree(top, path, mode, algorithm, roots)
if found is not None:
new[path] = found[:2]
contents[found[1]] = found[2]
return new, contents
def text_lines(text: str) -> list[str]:
lines = text.split("\n")
return lines[:-1] if lines[-1] == "" else lines
def secret_rules(root: Path | None) -> Rules | None:
"""Returns:
Per MEDUSA rule whose cwe names hard-coded credentials, its file
globs and compiled patterns (flags as MEDUSA compiles them); cached
in CACHE until a rule file changes. None without rules or PyYAML,
or when a rule file cannot be read or parsed.
"""
if root is None or not root.is_dir():
return None
files = sorted(root.rglob("*.yaml"))
signature = hashlib.sha256(json.dumps([[str(file.relative_to(root)), file.stat().st_size, file.stat().st_mtime_ns] for file in files]).encode()).hexdigest()
try:
cached = json.loads(CACHE.read_text(encoding="utf-8"))
if cached.get("signature") == signature:
return loaded(cached["rules"])
except (OSError, ValueError, AttributeError, KeyError, TypeError):
pass
raw = parsed_rules(files)
if raw is None:
return None
try:
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps({"signature": signature, "rules": raw}), encoding="utf-8")
except OSError:
pass
return loaded(raw)
def loaded(raw: list) -> Rules:
return [(tuple(map(str, globs)), compiled(list(map(str, patterns)))) for globs, patterns in raw]
def parsed_rules(files: list[Path]) -> list[list] | None:
try:
import yaml
except ImportError:
return None
found = []
for file in files:
try:
text = file.read_text(encoding="utf-8", errors="replace")
data = (yaml.safe_load(text) or {}) if SECRET_CWE.search(text) else {}
except (OSError, RecursionError, yaml.YAMLError):
return None
if not isinstance(data, list | dict):
return None
for rule in data if isinstance(data, list) else data.get("rules") or []:
if isinstance(rule, dict) and SECRET_CWE.match(str(rule.get("cwe") or "")) and rule.get("patterns"):
types = rule.get("file_types") or ["*"]
globs = [str(kind) if "*" in str(kind) else f"*.{str(kind).lstrip('.')}" for kind in types]
found.append([globs, [str(pattern) for pattern in rule["patterns"]]])
return found
def compiled(patterns: list[str]) -> Patterns:
found = []
for pattern in patterns:
try:
found.append(re.compile(pattern, re.IGNORECASE | re.MULTILINE))
except re.error:
continue
return found
def rules_root() -> Path | None:
found = sorted(RULES_HOME.glob(RULES_TAIL))
return found[-1] if found else None
def rule_patterns(path: str, rules: Rules) -> Patterns:
name = Path(path).name.lower()
return [pattern for globs, found in rules if any(fnmatch.fnmatchcase(name, glob.lower()) for glob in globs) for pattern in found]
def withheld(path: str, texts: tuple[str, str], rules: Rules | None) -> bool:
if not rules or not any(patterns for _, patterns in rules):
return True
patterns = [ARMOUR, *rule_patterns(path, rules)]
return any(pattern.search(text) for text in texts for pattern in patterns)
def entry(head: dict, before: bytes | None, after: bytes | None, rules: Rules | None) -> tuple[dict, list[str]]:
"""Args:
head: the entry's path, status and mode.
Returns:
The diff entry without its patch, and the patch lines; no lines for
a binary side, one over BLOB_LIMIT or content withheld.
"""
found = {**head, "additions": 0, "deletions": 0, "note": ""}
if before is None or after is None:
return found, []
if b"\0" in before[:BINARY_PROBE] or b"\0" in after[:BINARY_PROBE]:
return {**found, "status": "B"}, []
texts = (before.decode("utf-8", "replace"), after.decode("utf-8", "replace"))
lines = list(difflib.unified_diff(*map(text_lines, texts), lineterm="", n=CONTEXT))[2:]
found.update(additions=sum(1 for line in lines if line.startswith("+")), deletions=sum(1 for line in lines if line.startswith("-")))
if withheld(head["path"], texts, rules):
return {**found, "note": WITHHELD}, []
return found, lines
def content_of(side: tuple[str, str] | None, gitlink: bool, contents: dict[str, bytes | None]) -> bytes | None:
if side is None:
return b""
return None if gitlink else contents.get(side[1])
def review_diff(cwd: Path, everything: bool, rules: Path | None) -> list[dict]:
return built(cwd, sides(cwd, everything, None), rules)
def tree_diff(cwd: Path, tree: str, rules: Path | None) -> list[dict]:
return built(cwd, sides(cwd, False, tree), rules)
def fetched(cwd: Path, old: dict, new: dict, changed: list[str], contents: dict[str, bytes | None]) -> None:
needed = {side[path][1] for side in (old, new) for path in changed if path in side and side[path][0] != GITLINK and side[path][1] not in contents}
sizes = blob_sizes(cwd, needed)
contents.update(blobs(cwd, {oid for oid in needed if sizes[oid] <= BLOB_LIMIT}))
def shown_path(path: str) -> str:
shown = path.encode("utf-8", "surrogateescape").decode("utf-8", "backslashreplace")
if len(shown) > PATH_LIMIT:
raise DiffError(f"a changed path runs past {PATH_LIMIT} characters, the most a preview takes")
return shown
def path_entry(path: str, before: tuple[str, str] | None, after: tuple[str, str] | None, contents: dict[str, bytes | None], rules: Rules | None) -> tuple[dict, list[str]]:
status = "A" if before is None else "D" if after is None else "M"
gitlink = any(side is not None and side[0] == GITLINK for side in (before, after))
modes = list(dict.fromkeys(side[0] for side in (before, after) if side is not None))
head = {"path": shown_path(path), "status": status, "mode": " => ".join(modes)}
return entry(head, content_of(before, gitlink, contents), content_of(after, gitlink, contents), rules)
def built(cwd: Path, found_sides: tuple[dict, dict, dict[str, bytes | None]], rules: Path | None) -> list[dict]:
old, new, contents = found_sides
changed = sorted(path for path in {*old, *new} if old.get(path) != new.get(path))
if len(changed) > FILE_CAP:
raise DiffError(f"{len(changed)} paths changed, a preview lists at most {FILE_CAP}")
for path in changed:
shown_path(path)
fetched(cwd, old, new, changed, contents)
secrets = secret_rules(rules)
diff, spent, full = [], 0, False
for path in changed:
found, lines = path_entry(path, old.get(path), new.get(path), contents, secrets)
patch = "\n".join(lines)
size = len(json.dumps(patch, ensure_ascii=False).encode("utf-8"))
full = full or spent + size > DIFF_LIMIT
if full:
patch = ""
else:
spent += size
diff.append({**found, "patch": patch})
return diff
def commit_of(command: str, cwd: str) -> tuple[Path, bool] | None:
"""Returns:
The directory git commit runs in and whether it records every
tracked change (-a or --all), for a command that is one git commit,
optionally behind variable assignments, env -C and git -C; None for
any other command and for one whose commit the preview would not
match (a pathspec, -o, -i, -p, --no-all, an unknown or ambiguous long
option, git -c, or a GIT_ variable outside KEPT_VARS).
"""
try:
words = shlex.split(command)
except ValueError:
return None
if any(word in {";", "&&", "||", "|", "&"} for word in words):
return None
place, words = launched(Path(cwd), words)
if not words or Path(words[0]).name != "git":
return None
place, rest = past_git_options(place, words[1:])
mode = commit_mode(rest[1:]) if rest else None
return None if mode is None else (place, mode)
def launched(place: Path, words: list[str]) -> tuple[Path, list[str]]:
"""Returns:
The directory and the words of the program a command starts, past
variable assignments and env with -C or --chdir; no words when an
assignment names a GIT_ variable outside KEPT_VARS or env takes
another option.
"""
words = past_assignments(words)
if words and Path(words[0]).name == "env":
place, words = past_env_options(place, words[1:])
words = past_assignments(words)
return place, words
def past_assignments(words: list[str]) -> list[str]:
while words and (assigned := ASSIGNMENT.fullmatch(words[0])):
if assigned.group(1).startswith("GIT_") and assigned.group(1) not in KEPT_VARS:
return []
words = words[1:]
return words
def past_env_options(place: Path, words: list[str]) -> tuple[Path, list[str]]:
while words and words[0].startswith("-"):
if words[0] in ("-C", "--chdir") and len(words) > 1:
place, words = place / words[1], words[2:]
elif words[0].startswith("--chdir="):
place, words = place / words[0].partition("=")[2], words[1:]
else:
return place, []
return place, words
def past_git_options(place: Path, rest: list[str]) -> tuple[Path, list[str]]:
while rest and rest[0] != "commit":
if rest[0] == "-C" and len(rest) > 1:
place, rest = place / rest[1], rest[2:]
elif rest[0] in ("--no-pager", "-P", "--no-optional-locks"):
rest = rest[1:]
else:
return place, []
return place, rest
def commit_mode(words: list[str]) -> bool | None:
"""Returns:
True for -a or --all, False for the staged tree, None when the
commit records something else (NARROWING, -o, -i, -p or a pathspec).
"""
skip, everything = False, False
for at, word in enumerate(words):
if skip:
skip = False
continue
if word == "--":
return None if words[at + 1 :] else everything
found = option(word)
if found is None:
return None
everything, skip = everything or found[0], found[1]
return everything
def option(word: str) -> tuple[bool, bool] | None:
"""Returns:
Whether a git commit option word is -a or --all and whether the next
word is its argument, a long option named in full or by a prefix of
exactly one of COMMIT_LONGS (what git 2.56 lists for git commit
--git-completion-helper-all); None for NARROWING, -o, -i, -p, a
pathspec and a long option it names no single one of.
"""
if word.startswith("--"):
name = long_option(word[2:].partition("=")[0])
return None if name is None or name in NARROWING else (name == "all", name in LONG_ARGS and "=" not in word)
if word.startswith("-") and word != "-":
return cluster(word[1:])
return None
def long_option(name: str) -> str | None:
if name in COMMIT_LONGS:
return name
found = [known for known in COMMIT_LONGS if known.startswith(name)]
return found[0] if len(found) == 1 else None
def cluster(letters: str) -> tuple[bool, bool] | None:
"""Returns:
Whether a short option cluster holds -a and whether the next word is
its argument; None when it holds -o, -i or -p.
"""
everything = False
for at, letter in enumerate(letters):
if letter in "oip":
return None
everything = everything or letter == "a"
if letter in SHORT_ARGS:
return everything, at == len(letters) - 1 and letter in "mFcCtU"
return everything, False
+7 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import time
from .choosing import EVERYONE, answer_text
from .diffs import store_diff, valid_diff
from .errors import Conflict, Missing, ToolError
from .fields import (
COMMAND_LIMIT,
@@ -60,12 +61,16 @@ def option_of(option: object) -> dict:
class Asking:
def ask(self, body: bytes) -> dict:
payload = decode(body)
diff = valid_diff(payload.pop("diff")) if "diff" in payload else None
expect(payload, ("tool", *ASK_TEXTS, "timeout"))
values = {name: bounded(payload, name, limit) for name, limit in ASK_TEXTS.items()}
lifetime = seconds(payload, "timeout", ASK_LIFETIME)
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})
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None, "diff_files": None if diff is None else len(diff)}
ask_id = self.asks.fresh()
if diff is not None:
store_diff(self.reviews.pages, "ask", ask_id, diff)
record = self.asks.insert(ask_id, {**fields, "created_ts": created, "expires": created + lifetime})
self.automate("ask", record)
return {"id": record["id"], "status": record["status"]}
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from .errors import Missing, ToolError
from .records import read_json, write_atomic
from .signed import limits
DIFF_LIMIT = limits.DIFF_LIMIT
DIFF_BODY_LIMIT = limits.DIFF_BODY_LIMIT
FILE_CAP = limits.FILE_CAP
PATH_LIMIT = limits.PATH_LIMIT
STATUSES = ("A", "M", "D", "R", "B")
DIFF_KEYS = ("path", "status", "mode", "additions", "deletions", "patch", "note")
NOTES = ("", limits.WITHHELD)
MODE = re.compile(r"[0-7]{6}(?: => [0-7]{6})?")
DIFF_SUFFIX = {"review": ".review-diff.json", "ask": ".ask-diff.json"}
def counted(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def diff_entry(entry: object) -> dict:
if not isinstance(entry, dict) or set(entry) != set(DIFF_KEYS):
raise ToolError(f"every diff entry must be an object with exactly {', '.join(DIFF_KEYS)}")
path, patch, mode = entry["path"], entry["patch"], entry["mode"]
if not isinstance(path, str) or not 0 < len(path) <= PATH_LIMIT or entry["status"] not in STATUSES:
raise ToolError(f"a diff path must be 1 to {PATH_LIMIT} characters and its status one of {', '.join(STATUSES)}")
if not isinstance(mode, str) or not MODE.fullmatch(mode):
raise ToolError("mode must be a git file mode, or old => new for a changed one")
if not counted(entry["additions"]) or not counted(entry["deletions"]) or not isinstance(patch, str) or (entry["status"] == "B" and patch):
raise ToolError("additions and deletions must be counts, patch text, empty for a binary file")
if entry["note"] not in NOTES or (entry["note"] and patch):
raise ToolError(f"note must be empty or {limits.WITHHELD!r}, the latter with an empty patch")
return {key: entry[key] for key in DIFF_KEYS}
def valid_diff(value: object) -> list[dict]:
"""Returns:
The review diff of requirement 003, checked: at most FILE_CAP
entries, and at most DIFF_LIMIT bytes of JSON-encoded patch text in
all, the bound the requesting script keeps.
"""
if not isinstance(value, list) or len(value) > FILE_CAP:
raise ToolError(f"diff must be a list of at most {FILE_CAP} files")
entries = [diff_entry(entry) for entry in value]
if sum(len(json.dumps(entry["patch"], ensure_ascii=False).encode("utf-8")) for entry in entries) > DIFF_LIMIT:
raise ToolError(f"the patches of a diff must stay within {DIFF_LIMIT} bytes")
return entries
def diff_bytes(diff: list[dict]) -> bytes:
return json.dumps({"diff": diff}, ensure_ascii=False).encode("utf-8")
def store_diff(folder: Path, kind_name: str, item_id: str, diff: list[dict]) -> None:
write_atomic(folder / f"{item_id}{DIFF_SUFFIX[kind_name]}", diff_bytes(diff))
def stored_diff(folder: Path, kind_name: str, item_id: str) -> dict:
data = read_json(folder / f"{item_id}{DIFF_SUFFIX[kind_name]}") if kind_name in DIFF_SUFFIX else None
if not isinstance(data, dict) or not isinstance(data.get("diff"), list):
raise Missing(item_id)
return {"kind": kind_name, "id": item_id, "diff": data["diff"]}
+3 -2
View File
@@ -5,14 +5,15 @@ import math
import re
from .errors import ToolError
from .signed import limits
WAIT_CAP = 300
TEXT_LIMIT = 20_000
COMMAND_LIMIT = 4000
PATH_LIMIT = 4096
PATH_LIMIT = limits.PATH_LIMIT
NAME_LIMIT = 120
COUNT = re.compile(r"[0-9]{1,12}")
PATH = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}")
PATH = re.compile(rf"/[^\x00-\x1f\x7f]{{0,{PATH_LIMIT - 1}}}")
LINE = r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{{1,{0}}}"
ORDER_NAME = re.compile(r"(?![\s-])[^,\x00-\x1f\x7f]{1,200}")
FIELDS = {
+6 -1
View File
@@ -6,6 +6,7 @@ from collections.abc import Callable
from http import HTTPStatus
from urllib.parse import urlsplit
from .diffs import DIFF_BODY_LIMIT
from .engines import STT_LIMIT
from .exchange import PLAIN_CSP, Exchange
@@ -56,6 +57,7 @@ BOARD_SIGN_ROUTE = "/api/board/sign"
SETTINGS_SIGN_ROUTE = "/api/settings/sign"
CHOICES_ROUTE = "/api/choices"
CHOICES_SIGN_ROUTE = "/api/choices/sign"
DIFF_ROUTE = re.compile(r"/api/diffs/(review|ask)/([0-9a-f]{16})")
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE)
@@ -83,10 +85,13 @@ class Handler(Exchange):
cleanup = CLEANUP_STATE.fullmatch(path)
office = OFFICE_STATE.fullmatch(path)
voice = VOICE_ITEM.fullmatch(path)
diff = DIFF_ROUTE.fullmatch(path)
if self.host() is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
elif path == "/":
self.index()
elif diff:
self.answer(lambda: hub.diff_of(diff.group(1), diff.group(2)), HTTPStatus.OK)
elif vendored and vendored.group(1) in VENDOR:
self.vendored(*VENDOR[vendored.group(1)])
elif voice and voice.group(2):
@@ -237,7 +242,7 @@ class Handler(Exchange):
def do_POST(self) -> None:
path = urlsplit(self.path).path
length = self.body_length({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT, VOICE_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT))
length = self.body_length({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT, VOICE_ROUTE: STT_LIMIT, "/mcp": DIFF_BODY_LIMIT, "/api/asks": DIFF_BODY_LIMIT}.get(path, BODY_LIMIT))
if length is None:
return
host = self.host()
+4 -3
View File
@@ -19,16 +19,17 @@ SNOOZE_LIMIT = 8 * 86400
class Inbox:
def inbox(self, now: float, tasks: dict[str, dict], directory: Directory) -> list[dict]:
claims = read_claims(self.council)
base = {"agent": "", "to": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False, "deniable": False, "job": None, "rule": None, "dry_run": None}
base = {"agent": "", "to": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False, "deniable": False, "job": None, "rule": None, "dry_run": None, "diff": None}
items = []
for review in self.reviews.pending():
created = iso_epoch(review["created"]) or now
gate = review.get("gate", "")
fields = {"title": review["title"], "detail": review["branch"], "green": gate == "pass", "gate": gate, "score": review.get("score", ""), "directory": review["worktree"]}
agent = review.get("agent", "")
fields = {"title": review["title"], "detail": review["branch"], "green": gate == "pass", "gate": gate, "score": review.get("score", ""), "directory": review["worktree"], "agent": agent, "to": requester_of(claims, agent)[0], "diff": review.get("diff_files")}
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"]), "to": requester_of(claims, ask["session"])[0], "green": not warnings, "warnings": warnings, "deniable": True}
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "to": requester_of(claims, ask["session"])[0], "green": not warnings, "warnings": warnings, "deniable": True, "diff": ask.get("diff_files")}
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", "")
+7 -1
View File
@@ -110,11 +110,17 @@ class Reviews(Ledger):
def stored(self, review_id: str, suffix: str) -> Path:
return self.pages / f"{review_id}{suffix}"
def submit(self, fields: dict[str, str], page: bytes, files: list[dict]) -> dict:
def submit(self, fields: dict[str, str], page: bytes, files: list[dict], attached: dict[str, bytes]) -> dict:
"""Args:
attached: further files written before the review shows, by
suffix.
"""
with self.changed:
review_id = self.fresh()
write_atomic(self.stored(review_id, ".html"), page)
write_atomic(self.stored(review_id, ".json"), json.dumps({"files": files}).encode("utf-8"))
for suffix, data in attached.items():
write_atomic(self.stored(review_id, suffix), data)
return self.insert(review_id, {**fields, "files": len(files), "status": "pending", "decided": None})
+13 -5
View File
@@ -6,16 +6,18 @@ import stat
import time
from pathlib import Path
from .diffs import DIFF_SUFFIX, diff_bytes, stored_diff, valid_diff
from .errors import Conflict, Missing, ToolError
from .fields import WAIT_CAP, bounded, decode, expect, field, seconds
from .review_page import review_files
from .signed import review_signed
from .signed import limits, review_signed
PAGE_LIMIT = 32 << 20
NOTES_LIMIT = 100_000
NOTES_LIMIT = limits.NOTES_LIMIT
SCORE_LIMIT = 500
GATES = ("pass", "fail")
REQUEST = ("repo", "worktree", "branch", "tree", "title", "page", "notes", "gate", "score")
OPTIONAL = ("agent", "diff")
LISTED = ("id", "created", "worktree", "branch", "tree", "title")
STATE = ("id", "status", "tree", "decided")
@@ -55,7 +57,7 @@ class Reviewing:
return data
def request(self, arguments: dict) -> dict:
expect(arguments, REQUEST)
expect(arguments, (*REQUEST, *(name for name in OPTIONAL if name in arguments)))
fields = {name: field(arguments, name) for name in ("repo", "worktree", "branch", "tree", "title")}
notes = arguments["notes"]
if not isinstance(notes, str) or len(notes) > NOTES_LIMIT:
@@ -63,12 +65,18 @@ class Reviewing:
if arguments["gate"] not in GATES:
raise ToolError("gate must be pass or fail")
score = " ".join(bounded(arguments, "score", SCORE_LIMIT).split())
agent = field(arguments, "agent").strip() if "agent" in arguments else ""
diff = valid_diff(arguments["diff"]) if "diff" in arguments else None
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))
extra = {"notes": notes, "gate": arguments["gate"], "score": score, "agent": agent, "diff_files": None if diff is None else len(diff)}
attached = {} if diff is None else {DIFF_SUFFIX["review"]: diff_bytes(diff)}
review = self.reviews.submit({**fields, "title": fields["title"].strip(), **extra}, page, review_files(page), attached)
self.automate("review", review)
return {"id": review["id"], "status": review["status"], "url": self.url()}
def diff_of(self, kind_name: str, item_id: str) -> dict:
return stored_diff(self.reviews.pages, kind_name, item_id)
def review_proof(self, review_id: str) -> dict:
review = self.reviews.get(review_id)
if review is None:
+1
View File
@@ -30,6 +30,7 @@ def client_module(name: str, uses: dict[str, ModuleType]) -> ModuleType:
return module
limits = client_module("hub_limits", {})
signing = client_module("signing", {})
passkey = client_module("passkey", {"signing": signing})
verifier = client_module("verifier", {"signing": signing, "passkey": passkey})
+1 -1
View File
@@ -51,7 +51,7 @@ INDEX_CSP = (
def badges(inbox: list[dict], claims: list[dict], jobs: list[dict], now: float) -> dict[str, int]:
return {
"inbox": sum(1 for item in inbox if not item["until"]),
"tasks": sum(1 for item in inbox if not item["until"]),
"agents": len({claim["agent"] for claim in claims if claim["beat"] is not None and claim["beat"] >= now - ACTIVE}),
"artifacts": sum(1 for job in jobs if job["status"] in JOB_OPEN),
}
+60
View File
@@ -0,0 +1,60 @@
const Diffs = (() => {
"use strict";
const COLLAPSE = 400;
const QUOTE_LIMIT = 2000;
const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
const KINDS = {"+": "add", "-": "del", " ": "ctx"};
function rows(patch) {
const found = [];
let [old, now] = [0, 0];
String(patch).split("\n").forEach((text, index) => {
const line = index + 1;
const hunk = HUNK.exec(text);
if (hunk) {
[old, now] = [Number(hunk[1]), Number(hunk[2])];
found.push({kind: "hunk", line, old: "", new: "", text});
return;
}
const kind = KINDS[text[0]];
if (!kind) {
return;
}
const row = {kind, line, old: kind === "add" ? "" : old, new: kind === "del" ? "" : now, text: text.slice(1)};
old += kind === "add" ? 0 : 1;
now += kind === "del" ? 0 : 1;
found.push(row);
});
return found;
}
const collapsed = (file) => file.additions + file.deletions > COLLAPSE;
function span(numbers) {
const known = numbers.filter((value) => Number.isInteger(value) && value > 0);
return known.length ? [Math.min(...known), Math.max(...known)] : null;
}
function lineSpan(text, quote) {
const full = String(text);
const at = full.indexOf(quote);
if (!quote || at < 0) {
return [1, full.split("\n").length];
}
const from = full.slice(0, at).split("\n").length;
return [from, from + quote.split("\n").length - 1];
}
function quoted(text) {
const clean = String(text).trim();
return clean.length > QUOTE_LIMIT ? `${clean.slice(0, QUOTE_LIMIT)}…` : clean;
}
const feedback = (ref, path, from, to, quote, words) => `Feedback on ${ref} ${path}:${from}-${to}: "${quoted(quote)}" ${String(words).trim()}`;
const feedbackOf = (messages, ref) => messages.filter((message) => String(message.text).startsWith(`Feedback on ${ref} `));
return {COLLAPSE, QUOTE_LIMIT, rows, collapsed, span, lineSpan, quoted, feedback, feedbackOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Diffs;
}
+7 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% if state.badges.inbox %}({{ state.badges.inbox }}) {% endif %}Agent Orchestrator</title>
<title>{% if state.badges.tasks %}({{ state.badges.tasks }}) {% endif %}Agent Orchestrator</title>
<style nonce="{{ nonce }}">
:root {
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
@@ -413,6 +413,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-tasks.css" %}
{% include "style-bubbles.css" %}
{% include "style-diff.css" %}
</style>
</head>
<body>
@@ -455,6 +457,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "bubbles.js" %}
{% include "diffs.js" %}
{% include "nav.js" %}
{% include "popup.js" %}
@@ -502,6 +506,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-review.js" %}
{% include "page-diff.js" %}
{% include "page-filters.js" %}
{% include "page-stream.js" %}
+11 -3
View File
@@ -1,7 +1,8 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"]]};
const SUBS = {stream: [["rules", "Firewall"]]};
const PANES = {inbox: ["tasks", "operator"]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]];
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
@@ -15,10 +16,13 @@ const Nav = (() => {
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
function tabOf(screen) {
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
return Object.hasOwn(PANES, screen) ? PANES[screen][0] : Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
}
function hashOf(screen, view) {
if (Object.hasOwn(PANES, screen)) {
return `#${PANES[screen].join("/")}`;
}
return tabOf(screen) === screen ? (view ? `#${screen}/${view}` : `#${screen}`) : `#${tabOf(screen)}/${screen}`;
}
@@ -27,6 +31,10 @@ const Nav = (() => {
if (head === "stream" && sub === "radio") {
return {screen: "stream", view: null, redirect: "#stream", spot: true};
}
const pane = Object.keys(PANES).find((screen) => PANES[screen][0] === head && PANES[screen][1] === sub);
if (pane || (head === "stream" && sub === "inbox")) {
return {screen: pane || "inbox", view: null, redirect: pane ? null : hashOf("inbox", null)};
}
if (Object.hasOwn(OLD, head)) {
const [screen, fallback] = OLD[head];
const view = known(screen, sub) ? sub : fallback;
@@ -41,7 +49,7 @@ const Nav = (() => {
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
return {TABS, SUBS, PANES, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+2
View File
@@ -27,6 +27,8 @@
document.addEventListener("keydown", context, {once: true});
document.addEventListener("pointerdown", unlock);
document.addEventListener("keydown", keyboard);
document.addEventListener("contextmenu", feedbackMenu);
document.addEventListener("selectionchange", feedbackButton);
document.addEventListener("visibilitychange", refresh);
window.addEventListener("focus", refresh);
window.addEventListener("hashchange", apply);
+1 -1
View File
@@ -72,7 +72,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Ctrl+K", "Commands, views, agents and AI command"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: next / previous entry"], ["x", "Inbox: select entry"], ["a", "Inbox: grant entry (3 s to undo)"], ["Enter", "Inbox: open entry"], ["/", "Search the stream"], ["h", "Look at the sky: drag turns the view, wheel or two fingers zoom, Esc ends"], ["Arrows · + / − · 0", "Sky (focused): turn · zoom · reset"], ["Drag · wheel", "3D: turn, right drag pans, wheel zooms"],["🎙 hold", "Voice input into the agent field or the command line, never sent automatically"], ["?", "This help"], ["Esc", "Close"]];
const KEYS = [["Ctrl+K", "Commands, views, agents and AI command"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Waits on Operator: next / previous entry"], ["x", "Waits on Operator: select entry"], ["a", "Waits on Operator: grant entry (3 s to undo)"], ["Enter", "Waits on Operator: open entry"], ["f", "Feedback on the selected lines of a diff or a command"], ["/", "Search the stream"], ["h", "Look at the sky: drag turns the view, wheel or two fingers zoom, Esc ends"], ["Arrows · + / − · 0", "Sky (focused): turn · zoom · reset"], ["Drag · wheel", "3D: turn, right drag pans, wheel zooms"],["🎙 hold", "Voice input into the agent field or the command line, never sent automatically"], ["?", "This help"], ["Esc", "Close"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("en", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("en-GB", {dateStyle: "medium", timeStyle: "short"});
+161
View File
@@ -0,0 +1,161 @@
const DIFF_TONES = {A: "ok", M: "info", D: "bad", R: "violet", B: "neutral"};
const diffView = {cache: new Map(), version: 0, float: null, picked: null, timer: 0};
function diffOf(item) {
if (!item || typeof item.diff !== "number" || !["review", "ask"].includes(item.kind)) {
return null;
}
const key = keyOf(item);
if (!diffView.cache.has(key)) {
diffView.cache.set(key, "loading");
fetch(`/api/diffs/${item.kind}/${encodeURIComponent(item.id)}`, {cache: "no-store"})
.then((response) => (response.ok ? response.json() : {diff: null}))
.then((data) => diffView.cache.set(key, Array.isArray(data.diff) ? data.diff : null))
.catch(() => diffView.cache.set(key, null))
.then(() => {
diffView.version += 1;
drawInbox();
drawStream();
});
}
return diffView.cache.get(key);
}
function patchRow(row) {
const number = row.new || row.old || null;
if (row.kind === "hunk") {
return h("tr", {class: "hunk"}, h("td", {colspan: 3}), h("td", {class: "code"}, row.text));
}
return h("tr", {class: row.kind, "data-line": number},
h("td", {class: "num"}, row.old), h("td", {class: "num"}, row.new), h("td", {class: "sign"}, SIGNS[row.kind] || " "), h("td", {class: "code"}, row.text));
}
function diffBlock(file) {
const tone = DIFF_TONES[file.status] || "neutral";
const head = [h("span", {class: "letter", "data-tone": tone}, file.status), h("code", {class: "path"}, file.path),
file.mode && file.mode !== "100644" ? h("code", {class: "mode", title: `mode: ${file.mode}`}, file.mode) : null, h("span", {class: "spacer"}),
file.note ? h("span", {class: "note", title: file.note}, icon("bi-eye-slash"), file.note) : null,
file.status === "B" ? icon("bi-file-earmark-binary") : h("span", {class: "stat"}, h("span", {class: "add"}, `+${file.additions}`), h("span", {class: "del"}, `-${file.deletions}`))];
if (file.status === "B" || !file.patch) {
return h("section", {class: "diff-file", "data-path": file.path, "data-status": file.status, "data-patch": "none"}, h("div", {class: "diff-head"}, head));
}
const table = h("table", {class: "diff"}, h("tbody", {}, Diffs.rows(file.patch).map(patchRow)));
return h("section", {class: "diff-file", "data-path": file.path, "data-status": file.status},
h("details", {open: !Diffs.collapsed(file)}, h("summary", {class: "diff-head"}, head), h("div", {class: "diff-scroll"}, table)));
}
function diffBlocks(item) {
const found = diffOf(item);
if (found === null || found === undefined) {
return null;
}
if (found === "loading") {
return h("p", {class: "muted"}, "…");
}
return h("div", {class: "diff-files", "aria-label": `Diff: ${found.length}`}, found.map(diffBlock));
}
function feedbackList(ref) {
const sent = Diffs.feedbackOf(state.messages, ref);
return sent.length ? h("ul", {class: "feedback-list", "aria-label": `Feedback: ${sent.length}`}, sent.map((message) => h("li", {}, icon("bi-chat-left-text"), h("span", {class: "text"}, message.text), when(message.created_ts)))) : null;
}
function selectionFeedback() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount || !selection.toString().trim()) {
return null;
}
const range = selection.getRangeAt(0);
const elementOf = (node) => (node instanceof Element ? node : node.parentElement);
const region = elementOf(range.startContainer) && elementOf(range.startContainer).closest("[data-path]");
const card = region && region.closest("[data-ref]");
if (!card || !card.dataset.to || !elementOf(range.endContainer) || elementOf(range.endContainer).closest("[data-path]") !== region) {
return null;
}
const quote = selection.toString();
const code = region.querySelector("code");
const lines = region.classList.contains("command") && code
? Diffs.lineSpan(code.textContent, quote)
: Diffs.span([...region.querySelectorAll("tr[data-line]")].filter((row) => range.intersectsNode(row)).map((row) => Number(row.dataset.line)));
if (!lines) {
return null;
}
return {ref: card.dataset.ref, to: card.dataset.to, path: region.dataset.path, from: lines[0], until: lines[1], quote, rect: range.getBoundingClientRect()};
}
function feedbackSheet(picked) {
hideFeedback();
const words = h("textarea", {class: "field", rows: 4, "aria-label": "Feedback", title: "Feedback"});
const send = iconBtn("bi-send", "Send feedback", "btn primary", async () => {
if (!words.value.trim()) {
words.focus();
return;
}
send.disabled = true;
const sent = await radioSend({to: picked.to, text: Diffs.feedback(picked.ref, picked.path, picked.from, picked.until, picked.quote, words.value), upload: "", cls: "post", voice: ""});
send.disabled = false;
if (sent) {
closeSheet();
}
}, `Send feedback: a post to ${picked.to}`);
openSheet("Feedback", [
h("div", {class: "who"}, icon("bi-chat-left-text"), person(picked.to), h("code", {}, `${picked.path}:${picked.from}-${picked.until}`)),
h("pre", {class: "notes"}, Diffs.quoted(picked.quote)),
words,
h("div", {class: "actions"}, h("span", {class: "spacer"}), send)], false);
words.focus();
}
function hideFeedback() {
if (diffView.float) {
diffView.float.hidden = true;
}
diffView.picked = null;
}
function showFeedback(picked, x, y, menu) {
if (!diffView.float) {
diffView.float = h("div", {class: "feedback-float", hidden: true},
h("button", {type: "button", class: "btn small primary icon-only", ...named("Feedback", "Feedback (f): a post with the selected lines to the requesting agent"), on: {
mousedown: (event) => event.preventDefault(),
click: () => diffView.picked && feedbackSheet(diffView.picked),
}}, icon("bi-chat-left-text")));
document.body.append(diffView.float);
}
diffView.picked = picked;
diffView.float.setAttribute("role", menu ? "menu" : "toolbar");
diffView.float.firstChild.setAttribute("role", menu ? "menuitem" : "button");
diffView.float.style.left = `${Math.max(Math.min(x, window.innerWidth - 44), 4)}px`;
diffView.float.style.top = `${Math.max(Math.min(y, window.innerHeight - 44), 4)}px`;
diffView.float.hidden = false;
}
function feedbackButton() {
window.clearTimeout(diffView.timer);
diffView.timer = window.setTimeout(() => {
const picked = selectionFeedback();
if (picked) {
showFeedback(picked, picked.rect.right + 4, picked.rect.bottom + 4, false);
} else if (!diffView.float || !diffView.float.contains(document.activeElement)) {
hideFeedback();
}
}, 120);
}
function feedbackMenu(event) {
const picked = selectionFeedback();
if (!picked || !(event.target instanceof Element) || !event.target.closest("[data-path]")) {
return;
}
event.preventDefault();
showFeedback(picked, event.clientX, event.clientY, true);
diffView.float.firstChild.focus();
}
function feedbackKey() {
const picked = selectionFeedback();
if (picked) {
feedbackSheet(picked);
}
return Boolean(picked);
}
+23 -20
View File
@@ -152,32 +152,35 @@
function inboxItem(item, index) {
const red = item.kind === "review" && item.gate !== "pass";
const classes = ["item", isGreen(item) ? "green" : "", red ? "red" : "", item.held ? "held" : "", index === ui.cursor ? "cursor" : ""].filter(Boolean).join(" ");
const commanded = item.kind === "ask" || item.kind === "operator";
const classes = ["item", "entry", `k-${item.kind}`, "waiting", isGreen(item) ? "green" : "", red ? "red" : "", item.held ? "held" : "", index === ui.cursor ? "cursor" : ""].filter(Boolean).join(" ");
const box = h("input", {type: "checkbox", "aria-label": `Select ${item.title}`, checked: ui.selected.has(keyOf(item)), disabled: !pickable(item), on: {change: () => toggle(item, true)}});
return h("li", {class: classes, "data-index": index},
return h("li", {class: classes, "data-index": index, "data-ref": keyOf(item), "data-to": item.to || null},
box,
h("div", {},
h("div", {class: "head-row"},
pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]),
item.kind === "review" ? pill(item.gate === "pass" ? "Gate green" : "Gate red", item.gate === "pass" ? "ok" : "bad") : null,
item.warnings.map((warning) => pill(warning, "bad")),
item.pinned ? pill("pinned", "violet") : null,
item.until ? pill(`until ${absolute.format(stampOf(item.until))}`, "neutral") : null,
h("span", {class: "spacer"}), when(item.ts)),
h("button", {type: "button", class: "linkish", title: "Open", on: {click: () => openItem(item)}}, item.title),
item.agent ? h("div", {class: "muted"}, "from ", person(item.agent)) : null,
h("div", {class: "msg-meta"},
when(item.ts),
pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]),
item.kind === "review" ? pill(item.gate === "pass" ? "Gate green" : "Gate red", item.gate === "pass" ? "ok" : "bad") : null,
item.warnings.map((warning) => pill(warning, "bad")),
item.pinned ? pill("pinned", "violet") : null,
item.until ? pill(`until ${absolute.format(stampOf(item.until))}`, "neutral") : null),
h("div", {class: "entry-body"},
h("div", {class: "route"},
h("button", {type: "button", class: "linkish headline", title: "Open", on: {click: () => openItem(item)}}, item.kind === "operator" ? item.id : item.kind === "ask" ? item.title.split(": ")[0] : item.title),
item.agent ? [face(item.agent, false), 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 !== "-" ? ` · ${item.detail}` : "") : null,
commanded ? h("div", {class: "command", "data-path": item.directory || "-"}, h("code", {}, item.command), copyButton(item.command)) : null,
commanded ? h("span", {class: "muted"}, `in ${dash(item.directory)}`) : null,
item.kind === "operator" && item.detail && item.detail !== "-" ? h("p", {class: "text reason"}, item.detail) : null,
diffBlocks(item),
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" : ""}`, title: `${option.label}: your passkey signs this answer, a post to ${item.agent}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null),
h("div", {class: "item-actions"},
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null,
feedbackList(keyOf(item))),
h("div", {class: "item-actions line-actions"},
actionsFor(item),
item.kind === "operator" ? copyButton(item.command) : null,
h("span", {class: "spacer"}),
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: snooze for one hour"),
iconBtn("bi-sunrise", "tomorrow", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "tomorrow: snooze until 8 am tomorrow"),
@@ -202,7 +205,7 @@
ui.selected.delete(key);
}
}
const sig = JSON.stringify([items.map(withoutOutput), [...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), diffView.version, state.messages.map((message) => message.id)]);
if (sig === inboxSig) {
repaintConsoles(items);
return;
@@ -221,7 +224,7 @@
const deny = h("button", {type: "button", class: "btn small danger", ...named(`Deny selection (${deniable.length})`, `Deny selection (${deniable.length}): each requester gets its denial by post, one passkey signature, 3 s to undo`), disabled: !deniable.length || ui.busy, on: {click: () => bulk(chosen, "Deny selection", true)}}, icon("bi-x-square"), String(deniable.length));
const all = h("button", {type: "button", class: "btn small primary", ...named(`Grant all green (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Grant all green", false)}}, icon("bi-check2-all"), String(green.length));
inboxDom.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Waits on Operator", counter(waitingCount())), h("span", {class: "spacer"}),
hidden ? h("label", {class: "check"}, snoozed, `${hidden} snoozed`) : null),
h("div", {class: "inbox-actions"}, choose, deny, all),
onLocalhost() ? "" : h("p", {class: "muted"}, "The passkey signs grants only under ", localLink()),
+3 -1
View File
@@ -1,6 +1,6 @@
function helpSheet() {
openSheet("Keyboard shortcuts", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
h("p", {class: "muted"}, "Control room: full screen with large type for a second screen, under ⚙ Ambience. j, k, x, a and Enter work in the inbox.")], false);
h("p", {class: "muted"}, "Control room: full screen with large type for a second screen, under ⚙ Ambience. j, k, x, a and Enter work in Waits on Operator.")], false);
}
function overlayOpen() {
@@ -68,6 +68,8 @@
if (current && !current.held) {
approveOne(current);
}
} else if (event.key === "f" && feedbackKey()) {
event.preventDefault();
} else if (event.key === "Enter" && current && target === document.body) {
event.preventDefault();
openItem(current);
+3 -3
View File
@@ -60,7 +60,7 @@
function render(force) {
for (const [id, {button}] of navButtons) {
button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
button.setAttribute("aria-current", ui.screen === id || (!navButtons.has(ui.screen) && Nav.tabOf(ui.screen) === id) ? "page" : "false");
}
const sig = signature();
if (force || drawn.screen !== ui.screen || drawn.sig !== sig) {
@@ -98,13 +98,13 @@
const {badge} = navButtons.get(id);
badge.textContent = String(count);
badge.hidden = count === 0;
badge.className = `badge${id === "inbox" && count ? " urgent" : ""}`;
badge.className = `badge${id === "tasks" && count ? " urgent" : ""}`;
}
const banner = document.getElementById("mayday");
const silence = state.silence;
banner.hidden = !silence;
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday", []), "cancel: end the mayday (radio.py clear)")] : []));
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
document.title = `${state.badges.tasks ? `(${state.badges.tasks}) ` : ""}Agent Orchestrator`;
window.setTimeout(measure, 0);
}
+4 -2
View File
@@ -112,7 +112,7 @@
}
if (entry.kind === "ask" || entry.kind === "operator") {
const where = entry.kind === "ask" ? entry.ref.cwd : entry.ref.directory;
return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
return [h("div", {class: "command", "data-path": where || "-"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
}
if (entry.kind === "review") {
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "View diff", "btn small soft", () => reviewSheet(entry.ref.id)))];
@@ -134,11 +134,13 @@
const renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `now: ${entry.now}`}, person(entry.agent)) : person(entry.agent);
const who = entry.agent ? [face(entry.agent, false), renamed] : entry.session ? h("strong", {title: entry.session}, entry.session.slice(0, 8)) : null;
const headline = entry.kind === "transcript" || entry.kind === "ask" || entry.kind === "operator" ? null : h("span", {class: "headline"}, entry.title);
return h("li", {class: `entry k-${entry.kind}${entry.waiting ? " waiting" : ""}${alarm ? " alarm" : ""}`},
return h("li", {class: `entry k-${entry.kind}${entry.waiting ? " waiting" : ""}${alarm ? " alarm" : ""}`, "data-ref": item ? keyOf(item) : null, "data-to": item && item.to ? item.to : null},
h("div", {class: "msg-meta"}, when(entry.ts), kindPill(entry), extras(entry)),
h("div", {},
h("div", {class: "route"}, who, entry.sub ? h("button", {type: "button", class: "linkish muted", on: {click: () => focusSub(entry.session, entry.sub)}}, `› Subagent ${entry.sub.slice(0, 12)}`) : null, entry.kind === "ask" ? h("strong", {}, entry.title) : null, headline),
body(entry),
item ? diffBlocks(item) : null,
item ? feedbackList(keyOf(item)) : null,
item ? h("div", {class: "line-actions"}, actionsFor(item)) : null));
}
+23
View File
@@ -0,0 +1,23 @@
.inbox-list > .item.entry { grid-template-columns: auto 150px minmax(0, 1fr); align-items: start; padding: var(--sp-3); border: 1px solid var(--line); }
.inbox-list > .item.entry.waiting { box-shadow: inset 3px 0 0 var(--warn); }
.inbox-list > .item.entry.green { box-shadow: inset 3px 0 0 var(--ok); }
.inbox-list > .item.entry.red { box-shadow: inset 3px 0 0 var(--bad); }
.inbox-list > .item.entry > .entry-body { display: grid; gap: var(--sp-1); min-width: 0; }
.inbox-list > .item.entry .reason { margin: 0; }
@media (max-width: 640px) {
.inbox-list > .item.entry { grid-template-columns: auto minmax(0, 1fr); }
.inbox-list > .item.entry > .entry-body { grid-column: 1 / -1; }
}
.diff-files { display: grid; gap: var(--sp-2); min-width: 0; margin-top: var(--sp-1); }
.diff-file { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.diff-file > .diff-head, .diff-file summary.diff-head { padding: 6px 10px; cursor: default; }
.diff-file summary.diff-head { cursor: pointer; list-style: none; }
.diff-file summary.diff-head::-webkit-details-marker { display: none; }
.diff-file .diff-scroll { max-height: min(60vh, 640px); }
.diff-file .diff-head .mode { color: var(--warn); font-size: var(--fs-xs); }
.diff-file .diff-head .note .bi { margin-right: 4px; vertical-align: -2px; }
.diff-file .diff-head .note { display: inline-flex; align-items: center; color: var(--warn); font-size: var(--fs-xs); }
.feedback-list { display: grid; gap: 4px; margin: var(--sp-1) 0 0; padding: 0; list-style: none; }
.feedback-list li { display: flex; align-items: baseline; gap: 6px; padding: 4px 8px; border-radius: 8px; background: var(--info-soft); font-size: var(--fs-sm); }
.feedback-list .text { flex: 1; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.feedback-float { position: fixed; z-index: 40; padding: 2px; border-radius: 10px; background: var(--glass-card); box-shadow: var(--shadow); }