ci: let Claude's Bash run only the ui helper, and keep the token out of the upload

Claude Code runs read-only commands (echo, cat, env) without asking in every permission mode, and Bash inherits CLAUDE_CODE_OAUTH_TOKEN, so allow rules alone could not stop a session from printing it (found on the desktop lane). claude-guard.py, a PreToolUse hook passed with --settings, lets through only one ui command with plain arguments and logs every decision to <layout>-guard.log, outside the session's directory. After each session, the session's files and the guard log are searched for the token (given to grep through a pipe); if it is there they are deleted before the upload and the step fails. Run 38729704027's session ran only ui commands and its files contain no token.
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-15 13:27:16 +00:00
1 parent dec7599590
commit b3dd8b7bf8
2 files changed
+41

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# PreToolUse hook for Claude on the runner: lets through only Bash commands that run the ui helper, with plain
# arguments, so the session has no general shell. Claude Code runs read-only commands such as cat, echo and env without
# asking in every permission mode, and Bash inherits CLAUDE_CODE_OAUTH_TOKEN, so permission rules alone are not enough.
# Exits 2 for anything else; Claude sees the message as the reason. Each decision is appended to CLAUDE_GUARD_LOG
# when it is set, as evidence that the hook ran.
import json
import os
import re
import sys
# nothing that chains, pipes, redirects, substitutes, expands or escapes
UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?")
call = json.load(sys.stdin)
command = (call.get("tool_input") or {}).get("command", "").strip()
allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command)
if os.environ.get("CLAUDE_GUARD_LOG"):
with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log:
log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n")
if not allowed:
print(f"Only the ui helper runs here, as one command: ui <command> [args]. Refused: {command[:200]}", file=sys.stderr)
sys.exit(2)
+16
View File
@@ -30,6 +30,10 @@ max_budget=$(scope max-budget-usd)
export PATH="$dir:$PATH" BACKEND_DIR APP_LOG=$out/app.log CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
cp "$BACKEND_DIR/seed.md" "$out/seed.md"
settings=$out/settings.json
cat > "$settings" << EOF
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$dir/claude-guard.py"}]}]}}
EOF
xcrun simctl spawn "$SIM_UDID" log stream --level debug --style compact --predicate 'process == "SimpleX"' > "$APP_LOG" 2>&1 &
log_pid=$!
@@ -77,6 +81,9 @@ for layout in $layouts; do
write_prompt "$layout" "$previous" > "$session/prompt.md"
echo "::group::Claude session: $layout, cases $cases, $model, $max_turns turns, \$$max_budget"
session_status=0
# outside the session's directory, so the session cannot change it
export CLAUDE_GUARD_LOG=$out/$layout-guard.log
: > "$CLAUDE_GUARD_LOG"
(
cd "$session"
UI_OUT=$session/shots CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \
@@ -92,6 +99,7 @@ for layout in $layouts; do
--max-budget-usd "$max_budget" \
--autocompact 200k \
--no-session-persistence \
--settings "$settings" \
--output-format stream-json \
--verbose \
< prompt.md > transcript.jsonl 2> stderr.log
@@ -103,7 +111,15 @@ for layout in $layouts; do
else
echo "no report.md"
fi
echo "guard hook: $(grep -c '^allow' "$CLAUDE_GUARD_LOG" || true) allowed, $(grep -c '^refuse' "$CLAUDE_GUARD_LOG" || true) refused"
grep '^refuse' "$CLAUDE_GUARD_LOG" || true
echo "::endgroup::"
# the token must never be uploaded, whatever the session did; grep reads it from a pipe, not from its arguments
if grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG"; then
rm -rf "$session" "$CLAUDE_GUARD_LOG"
echo "::error::the $layout session's files contained the token, so they were deleted before the upload"
session_status=1
fi
if [ "$session_status" -ne 0 ]; then
status=$session_status
fi