Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ee733de
Add an experimental Claude PR review CI check
jkmassel Oct 28, 2026
c49eefc
Give the Claude PR review the Xcode MCP tools
jkmassel Oct 28, 2026
11e9001
Grant headless Xcode the checkout folder for the Claude PR review
jkmassel Oct 28, 2026
a65da95
Start the Claude PR review without waiting for the Jetpack build
jkmassel Oct 28, 2026
2d40609
Address the Claude PR review's own findings
jkmassel Oct 28, 2026
ac2066f
Post the full Claude PR review result as a PR comment
jkmassel Oct 28, 2026
6e1bfe4
Show Claude PR review screenshots on GitHub via Spacefast
jkmassel Oct 28, 2026
30f402a
Run the Claude PR review as a separate macOS user
jkmassel Oct 28, 2026
5725111
Unmount the VM's shared credentials before the Claude review
jkmassel Oct 28, 2026
483ac9e
Don't copy the reviewer's evidence back as root
jkmassel Oct 28, 2026
ec3cc27
Write the reviewer's Xcode MCP setting to its own home
jkmassel Oct 29, 2026
8b2625d
Have the reviewer enable Xcode MCP from its own session
jkmassel Oct 29, 2026
5d9bc78
Give the reviewer an unlocked keychain before enabling Xcode MCP
jkmassel Oct 29, 2026
b99ce62
Only offer Xcode MCP to the review when the bridge really starts
jkmassel Oct 29, 2026
d864a80
Let the Claude PR review tap around and create WordPress test sites
jkmassel Oct 29, 2026
e06140b
Address the Claude PR review's test site and AXe findings
jkmassel Oct 29, 2026
461880a
Validate end to end that the Claude PR review can test real PRs
jkmassel Oct 29, 2026
7989ae7
Pass the setup validation flag as an argument
jkmassel Oct 29, 2026
47aabe0
Publish review screenshots to jkmassel-screenshots; check the secrets…
jkmassel Oct 29, 2026
05bcfbb
Make the secrets env checker unable to print parts of values
jkmassel Oct 29, 2026
103f327
Stop running the Claude review setup validation on every build
jkmassel Oct 29, 2026
36a3b77
Remove the temporary secrets env file check
jkmassel Oct 29, 2026
7315c6f
Make the Claude review wait for its builds
jkmassel Oct 29, 2026
c3fd877
Keep only the current Claude review verdict on a PR
jkmassel Oct 29, 2026
af06425
Re-run the Claude review when wpmobilebot is asked to review
jkmassel Oct 29, 2026
86c9dcb
Record everything the Claude PR review does on the VM
jkmassel Oct 29, 2026
24d8a45
Keep the transcript's output away from the test site servers
jkmassel Oct 29, 2026
2397b2b
Show the Claude review's tool calls in the build log as they happen
jkmassel Oct 29, 2026
93ce08d
Write the Claude transcript to an append-only file
jkmassel Oct 30, 2026
6692ed9
Don't wait forever for the audit logs to stop
jkmassel Oct 30, 2026
dafd975
Keep the Anthropic key out of the audit logs
jkmassel Oct 30, 2026
3663687
Upload the audit logs during the Claude review, and cut their noise
jkmassel Oct 30, 2026
1a14944
Compress large audit summaries before upload
jkmassel Oct 30, 2026
56389e1
Keep the Anthropic key out of Claude's launch arguments and the audit…
jkmassel Oct 30, 2026
afaa27c
Stop the reviewer's processes before any step that uses a secret
jkmassel Oct 30, 2026
5c972db
Upload the audit results as one archive
jkmassel Oct 30, 2026
89637ab
Clear the Claude review request however the run ends
jkmassel Oct 30, 2026
bea8ec8
Give the Claude reviewer the Swift packages up front
jkmassel Oct 30, 2026
b4dec67
TEMP: run the setup validation to measure a real build
jkmassel Oct 30, 2026
09c2c2a
Revert "TEMP: run the setup validation to measure a real build"
jkmassel Oct 30, 2026
a6cbd62
Merge remote-tracking branch 'origin/trunk' into jkmassel/claude-bloc…
jkmassel Oct 30, 2026
3bc1f20
Restore the Swift package cache for the Claude reviewer
jkmassel Oct 30, 2026
ff864c7
TEST: fail right after setup, with only the Claude review step
jkmassel Oct 30, 2026
5854fce
TEST: wait instead of running Claude, to be cancelled
jkmassel Oct 30, 2026
fd6b660
Clear the Claude review request when the job starts
jkmassel Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .buildkite/claude/audit-cut.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Copies the lines added to a growing log since the last call into a new file.

Used to upload the Claude PR review's audit logs in parts while the review
runs. Only whole lines are copied, unless --all is given (at the end, when
nothing more will be written). The position reached is kept in <state file>.

Exit status: 0 = a part was written, 1 = nothing new.

Usage: audit-cut.py [--all] <log> <state file> <part>
"""

import os
import sys

args = sys.argv[1:]
take_all = args[0] == "--all"
if take_all:
args = args[1:]
log_path, state_path, part_path = args

try:
offset = int(open(state_path).read().strip() or 0)
except (OSError, ValueError):
offset = 0

try:
with open(log_path, "rb") as log:
log.seek(offset)
data = log.read()
except OSError:
sys.exit(1)

if not take_all:
data = data[: data.rfind(b"\n") + 1]
if not data:
sys.exit(1)

with open(part_path, "wb") as part:
part.write(data)
with open(state_path, "w") as state:
state.write(str(offset + len(data)))
119 changes: 119 additions & 0 deletions .buildkite/claude/audit-es-filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Filters eslogger's output for the Claude PR review audit log.

Reads eslogger JSON lines on stdin (already narrowed by grep to lines that
mention the reviewer's user ID) and writes the reviewer's events to gzip
parts, starting a new part every <rotate seconds>. Each finished part is
renamed to es-events.<n>.jsonl.gz, so the uploader only ever sees whole parts.

To keep the log useful and safe:
- `exec` events lose their environment, which holds the Anthropic key.
- Any of this job's secrets, read one per line from file descriptor 3, is
replaced with "(redacted)" anywhere in an event, e.g. in a command's args.
- `close` events for files that weren't changed are dropped.
- Read-only opens of system files, Xcode, Homebrew and the reviewer's own
home and temporary folders are counted instead of logged. Reads anywhere
else (other users' files, /etc, /opt/ci, /Volumes) are kept.

On exit it writes es-dropped.json with what was counted instead of logged.

Usage: audit-es-filter.py <reviewer uid> <reviewer home> <output folder> <rotate seconds>
"""

import collections
import gzip
import json
import os
import sys
import time

uid, home, out_dir, rotate_seconds = int(sys.argv[1]), sys.argv[2].rstrip("/"), sys.argv[3], int(sys.argv[4])

# Read-only opens under these are counted, not logged.
QUIET_READS = (
"/System/", "/usr/", "/bin/", "/sbin/", "/dev/",
"/Library/Apple/", "/Library/Developer/CoreSimulator/", "/Library/Frameworks/",
"/Applications/Xcode", "/opt/homebrew/", "/private/var/db/", "/private/var/folders/",
home + "/",
)
FWRITE = 0x2

try:
with os.fdopen(3) as secrets_file:
SECRETS = [line.strip() for line in secrets_file if line.strip()]
except OSError:
SECRETS = []

dropped = collections.Counter()
kept = 0
part_number = 0
part = None
part_started = 0.0


def part_path(number, temporary=False):
return os.path.join(out_dir, f"es-events.{number:05d}.jsonl.gz" + (".tmp" if temporary else ""))


def finish_part():
global part
if part is not None:
part.close()
os.rename(part_path(part_number, temporary=True), part_path(part_number))
part = None


def write(line):
global part, part_number, part_started, kept
now = time.monotonic()
if part is not None and now - part_started >= rotate_seconds:
finish_part()
if part is None:
part_number += 1
part = gzip.open(part_path(part_number, temporary=True), "wt", compresslevel=1, encoding="utf-8")
part_started = now
part.write(line + "\n")
kept += 1


def quiet_read(event):
body = event.get("open")
if not isinstance(body, dict) or (body.get("fflag") or 0) & FWRITE:
return None
path = (body.get("file") or {}).get("path") or ""
for prefix in QUIET_READS:
if path.startswith(prefix):
return "~/" if prefix == home + "/" else prefix
return None


try:
for raw in sys.stdin:
try:
message = json.loads(raw)
except ValueError:
dropped["not JSON"] += 1
continue
token = (message.get("process") or {}).get("audit_token") or {}
if token.get("euid") != uid and token.get("ruid") != uid:
continue
event = message.get("event") or {}
if isinstance(event.get("exec"), dict):
event["exec"]["env"] = "(removed)"
close = event.get("close")
if isinstance(close, dict) and not close.get("modified"):
dropped["close of an unchanged file"] += 1
continue
prefix = quiet_read(event)
if prefix:
dropped[f"read-only open under {prefix}"] += 1
continue
line = json.dumps(message, separators=(",", ":"))
for secret in SECRETS:
if secret in line:
line = line.replace(secret, "(redacted)")
dropped["secret redacted"] += 1
write(line)
finally:
finish_part()
with open(os.path.join(out_dir, "es-dropped.json"), "w") as f:
json.dump({"kept": kept, "parts": part_number, "counted_not_logged": dict(dropped.most_common())}, f, indent=2)
51 changes: 51 additions & 0 deletions .buildkite/claude/audit-hook-listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Receives Claude Code hook records for the Claude PR review audit log.

Runs as the build user, so the reviewer user can add records but can't change
or delete them. Each connection carries one hook event; it is written as one
JSON line, and only then acknowledged, so a tool never runs unlogged.

Usage: audit-hook-listener.py <log file> <port file>
"""

import datetime
import json
import socket
import socketserver
import sys
import threading

MAX_RECORD_BYTES = 16 * 1024 * 1024

log_path, port_path = sys.argv[1:3]
log = open(log_path, "a", buffering=1, encoding="utf-8")
lock = threading.Lock()


class Handler(socketserver.StreamRequestHandler):
def handle(self):
data = self.rfile.read(MAX_RECORD_BYTES + 1)
try:
record = json.loads(data[:MAX_RECORD_BYTES])
except ValueError:
record = data[:MAX_RECORD_BYTES].decode("utf-8", "replace")
line = json.dumps({
"received_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"bytes": len(data),
"truncated": len(data) > MAX_RECORD_BYTES,
"record": record,
})
with lock:
log.write(line + "\n")
log.flush()
self.wfile.write(b"ok\n")


class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = False


server = Server(("127.0.0.1", 0), Handler)
with open(port_path, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()
32 changes: 32 additions & 0 deletions .buildkite/claude/audit-hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Claude Code hook for the Claude PR review audit log.

Installed root-owned next to the managed settings, so the reviewer can't
change it. Sends the hook's input to the build user's listener
(audit-hook-listener.py). A PreToolUse event that can't be logged blocks the
tool (exit 2), so nothing runs without a record.

Usage: audit-hook.py <listener port>
"""

import json
import socket
import sys

port = int(sys.argv[1])
data = sys.stdin.buffer.read()

try:
event = json.loads(data).get("hook_event_name", "")
except ValueError:
event = ""

try:
with socket.create_connection(("127.0.0.1", port), timeout=20) as conn:
conn.sendall(data)
conn.shutdown(socket.SHUT_WR)
if conn.makefile("rb").readline().strip() != b"ok":
raise OSError("the audit log did not acknowledge the record")
except OSError as error:
if event == "PreToolUse":
print(f"Blocked: this tool call could not be written to the audit log ({error}).", file=sys.stderr)
sys.exit(2)
Loading