diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py new file mode 100755 index 0000000000..ab131ae997 --- /dev/null +++ b/scripts/ci/ios-sim/claude-guard.py @@ -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 [args]. Refused: {command[:200]}", file=sys.stderr) + sys.exit(2) diff --git a/scripts/ci/ios-sim/claude-test.sh b/scripts/ci/ios-sim/claude-test.sh index 1001308d50..04f7614e3e 100755 --- a/scripts/ci/ios-sim/claude-test.sh +++ b/scripts/ci/ios-sim/claude-test.sh @@ -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