From c229dcd2cad5286ae99140edfc90c5c90d1d1b3e Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:55:19 +0000 Subject: [PATCH] ci: the ui helpers check wait times are numbers, and the Claude guard refuses unquoted glob, brace and tilde, so no file name a session creates can run a command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard checks the command text before the Bash tool's shell expands it, and the session can create files with any name in its working directory. So `ui wait x S*`, with a file named `SECONDS[$(…)]` beside it, passed the guard, and the iOS and desktop helpers put the expanded name into `$((SECONDS + …))`, where bash runs the subscript's command substitution, in a process whose ancestors' environment holds the Claude token. Android's helper already checked the number. Now the desktop helper checks `wait` and `click-text` seconds as Android's does, the iOS helper checks them in `wait_for`, and both guards refuse `*?[]{}~` outside quotes (the plans quote all text, and inside quotes these don't expand). --- scripts/ci/desktop-linux/claude-guard.py | 10 ++++++++-- scripts/ci/desktop-linux/ui | 2 ++ scripts/ci/ios-sim/claude-guard.py | 10 ++++++++-- scripts/ci/ios-sim/ui | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/ci/desktop-linux/claude-guard.py b/scripts/ci/desktop-linux/claude-guard.py index 7b004584b2..fb0099c448 100755 --- a/scripts/ci/desktop-linux/claude-guard.py +++ b/scripts/ci/desktop-linux/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") 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) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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") diff --git a/scripts/ci/desktop-linux/ui b/scripts/ci/desktop-linux/ui index dd1b230a07..1ef9c21940 100755 --- a/scripts/ci/desktop-linux/ui +++ b/scripts/ci/desktop-linux/ui @@ -236,6 +236,7 @@ case "${1:-}" in ;; wait) secs=${3:-30} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "wait \"$2\": not on screen after $secs s" exit 1 @@ -253,6 +254,7 @@ case "${1:-}" in ;; click-text) secs=${3:-20} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "click-text \"$2\": not on screen after $secs s" exit 1 diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py index ab131ae997..c335b9836a 100755 --- a/scripts/ci/ios-sim/claude-guard.py +++ b/scripts/ci/ios-sim/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") 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) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", 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") diff --git a/scripts/ci/ios-sim/ui b/scripts/ci/ios-sim/ui index 674e948505..a705155388 100755 --- a/scripts/ci/ios-sim/ui +++ b/scripts/ci/ios-sim/ui @@ -71,6 +71,7 @@ point() { wait_for() { local text=$1 secs=${2:-30} + number "$secs" local deadline=$((SECONDS + secs)) t until t=$(tree 2> /dev/null) && grep -qF -- "$text" <<< "$t"; do if [ "$SECONDS" -ge "$deadline" ]; then