From 7f7bf096b80df221dadf5a292de0834109c1c373 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 03:14:45 +0000 Subject: [PATCH 1/3] fix(guardrails): skip Git-for-Windows usertemp /tmp; judge curl/wget dests (#4251) On a stock Git for Windows install /tmp is a usertemp mount of %TEMP%, so blocking Bash-tool writes there was a false positive. Skip that spelling on the Bash command lane when cygpath or the mount table says so. /c/tmp, C:\tmp, PowerShell /tmp, and the file-path lane stay blocked. curl -o/--output and wget -O/--output-document destinations are judged the same way as cp/mv. Co-authored-by: Kyle Sexton --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 7 + plugins/guardrails/README.md | 4 +- .../hooks/block-windows-drive-tmp.sh | 171 ++++++++++++++++-- .../hooks/block-windows-drive-tmp.test.sh | 46 +++++ 5 files changed, 216 insertions(+), 14 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 17564174ac..1b4d31c813 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -52,7 +52,7 @@ "block_windows_drive_tmp_enabled": { "type": "boolean", "title": "block-windows-drive-tmp guard", - "description": "Block writes whose target is a Windows drive-root temp path (/tmp, C:\\tmp, \\tmp, /c/tmp) that resolves to :\\tmp instead of %TEMP%, in both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes", + "description": "Block writes whose target is a Windows drive-root temp path (/tmp, C:\\tmp, \\tmp, /c/tmp) that resolves to :\\tmp instead of %TEMP%, in both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes. On Git for Windows, a Bash-tool /tmp that cygpath/mount shows is the usertemp mount of %TEMP% is not blocked; /c/tmp, C:\\tmp, drive-root \\tmp, PowerShell /tmp, and the file-path lane still are. curl -o/--output and wget -O/--output-document destinations are judged the same way as cp/mv", "default": true }, "block_exported_msys_pathconv_enabled": { diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index bf088604e3..01d7a47cc7 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.39.2] - 2026-09-28 + +### Fixed + +- **`block-windows-drive-tmp` no longer blocks a Bash-tool `/tmp` that already is `%TEMP%`** ([#4251](https://github.com/melodic-software/claude-code-plugins/issues/4251)). On a stock Git for Windows install `/tmp` is a `usertemp` mount of the platform temp (`cygpath -w /tmp` equals `%TEMP%`), so `mkdir -p /tmp/x` was a false positive. One cached probe per hook process: when `cygpath -w /tmp` matches `%TEMP%`/`%TMP%`, or the `mount` line for `/tmp` carries `usertemp`, the Bash command lane skips the POSIX `/tmp` arm. `/c/tmp`, `C:\tmp`, drive-root `\tmp`, PowerShell `/tmp`, and the Write/Edit file-path lane stay blocked. Linux CI's `/tmp` tmpfs has no `usertemp` flag, so the existing OSTYPE=msys fixtures still deny. +- **`curl -o` / `wget -O` destinations are judged.** `curl -sS -o /tmp/x https://example.com` and `wget -O /tmp/a.html https://example.com` exited 0 while `mkdir` and `cp` of the same path exited 2. A dest-flag walker reads `-o`/`--output` and `-O`/`--output-document` (space, `=`, and glued `-oFILE` forms); a URL that merely contains `/tmp` is not a write target. + ## [0.38.13] - 2026-09-28 ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index a7bde24f46..d2a89f7493 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -57,7 +57,7 @@ The [hook budget accounting](#hook-budget-accounting) carries the measurement. | **block-no-verify** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Git hook-bypass attempts on `git commit` / `git push`: `--no-verify` / `-n`, `core.hooksPath=` assignment, and hook-manager disable env vars, a configurable prefix set defaulting to `lefthook`, `husky`, `pre_commit`, `simple_git_hooks` (e.g. `LEFTHOOK=0`, `HUSKY=0`, `PRE_COMMIT_*=false`), tunable via `block_no_verify_hook_manager_prefixes`, including inside compound `cd … && …` commands. | | **block-dangerous-git** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Irreversible git operations: `push --force`/`-f` plus the equivalent leading-`+` refspec and `--mirror` forms, and the unsafe `--force-with-lease` spellings, in the two kinds git itself treats differently. **No expected value** (bare `--force-with-lease` or `=`) leases against the remote-tracking ref, which git documents as "trivially defeated" by a background fetch, blocked unless `--force-if-includes` is present, which git documents as the mitigation for exactly this form. **A movable `=:`**, such as `origin/main`, `HEAD`, a tag, an *abbreviated* object id, or hex of the wrong width for this repository's hash format, all of which git resolves at push time, and gitrevisions resolves a short hex word as a ref before trying it as an object-id prefix, is blocked unconditionally, because git declares `--force-if-includes` a no-op alongside an explicit `:`. A lease passes only when `` is immutable: a **literal** object id of the pushed repository's own hash width (detection never evaluates substitutions, so resolve it with `git rev-parse` as a separate step and pass the result) (40 hex under SHA-1, 64 under SHA-256, read from `git rev-parse --show-object-format` with the command's own `-C`/`--git-dir`/`--work-tree`/`--namespace` replayed onto it; undeterminable fails closed) or the empty string asserting the ref must not exist. The other width is a ref name there, not an object id. git ignores a ref whose name is full-width hex for its own format, but resolves one of the other width like any name. git scopes a pin to its own ref, so a bare fallback alongside a pinned entry still governs every other ref being updated; where the same ref carries several lease entries, git consults the first, and so does this guard. A trailing `--no-force-with-lease` cancels every previous lease, and a push dry-run disarms the check. Also blocked: `reset --hard`, `clean` with a force flag (any dry-run flag disarms), worktree-wide `checkout`/`restore` pathspecs (`.`, `:/`, `:(top…)`; path-scoped forms and `restore --staged .` pass), and forced `checkout -f` / `switch --discard-changes`. Accepted unique-prefix abbreviations of the blocked long options match too. `branch -D` is deliberately not blocked (reflog-recoverable; sanctioned skill flows issue it). Per-repo/per-user allow-list via the `block_dangerous_git_allow` userConfig option (comma list, any subset of `push-force,push-lease-unsafe,reset-hard,clean-force,checkout-dot,restore-dot,checkout-force`). | | **block-hook-bypass** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates: `cat > file`, `echo … > file`, inline python code with file-write indicators (`python`/`python3`/`py`/`pypy`, with `-c` or reading the program from stdin as `python3 - <:\tmp` instead of `%TEMP%` and accumulate at the volume root. On the **command lane** redirects and write utilities (`mkdir`/`mktemp`/`tee`/`cp`/`Set-Content`/`Out-File`/…) are blocked; on the **file-path lane** (since **0.30.0**) the tool's own `file_path` / `notebook_path` is matched directly, because on a Write the path *is* the write target. Both lanes call one matcher, so the same spellings block and the same ones pass. Does not fire on non-Windows hosts; leaves `%TEMP%` / `$TEMP` / `$TMPDIR` / `$env:TEMP` / `/var/tmp`, relative `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2` and UNC `\\server\tmp` alone. | +| **block-windows-drive-tmp** | PreToolUse · Bash \| PowerShell **and** Write \| Edit \| MultiEdit \| NotebookEdit | **Blocks** (exit 2) | Windows-only: write targets that are a drive-root temp path: POSIX `/tmp`, MSYS `/c/tmp`, `C:\tmp`, or drive-root `\tmp`, which resolve to `:\tmp` instead of `%TEMP%` and accumulate at the volume root. On a stock Git for Windows install the Bash-tool POSIX `/tmp` is a `usertemp` mount of `%TEMP%` itself, so that spelling is not blocked there; `/c/tmp`, `C:\tmp`, `\tmp`, PowerShell `/tmp`, and the file-path lane still are. On the **command lane** redirects and write utilities (`mkdir`/`mktemp`/`tee`/`cp`/`curl -o`/`wget -O`/`Set-Content`/`Out-File`/…) are blocked; on the **file-path lane** (since **0.30.0**) the tool's own `file_path` / `notebook_path` is matched directly, because on a Write the path *is* the write target. Both lanes call one matcher, so the same spellings block and the same ones pass. Does not fire on non-Windows hosts; leaves `%TEMP%` / `$TEMP` / `$TMPDIR` / `$env:TEMP` / `/var/tmp`, relative `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2` and UNC `\\server\tmp` alone. | | **block-exported-msys-pathconv** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Windows-only: an **exported** `MSYS_NO_PATHCONV` / `MSYS2_ARG_CONV_EXCL` (also the `declare -x` / `typeset -x` spellings), which switches off MSYS argv rewriting for every *later* command in the same command string. A later path argument then reaches a Windows-native program unconverted and git resolves its leading `/` against the current drive, so `git worktree add /d/worktrees/x` creates `:\d\worktrees\x` (#2870). Deliberately keys on the environment, not on a path shape: the incident command's path argument was identical to one that had already worked. A prefix whose command word is a **shell** (`MSYS_NO_PATHCONV=1 bash -c '…'`, `env … sh -c '…'`) blocks too: the prefix scopes to one *process*, and when that process is an interpreter, one process is every command inside it. A prefix on a **non-shell** command word (`MSYS_NO_PATHCONV=1 git show …`) and a bare assignment are not matched. The first scopes to exactly that command, and the second has no effect at all because the MSYS runtime reads the environment. Does not fire on non-Windows hosts. | | **block-root-delete-target** | PreToolUse · Bash | **Blocks** (exit 2) | A recursive `rm` whose target normalizes to a filesystem root. The command is parsed the way the shell builds argv, through launchers (`sudo`, `env`, `timeout`, the util-linux family including `setpriv` and `prlimit`, `systemd-run`), child shells (`bash -c`, `su -c`, `sg`), `eval` and command substitutions, and a segment is judged when its command word is `rm` with a recursive flag. Roots: `/` and `/*`, the MSYS-translated bare backslash in both its quoted (`rm -rf "\\"`, the shape behind the whole-volume loss in anthropics/claude-code#92593, which no permission pattern keyed on `rm -rf /*` matches) and **dangling** (`rm -rf \`) spellings, `~`, a literal `$HOME` / `${HOME}`, a drive root (`C:\`, `c:/`, `C:`), an MSYS, WSL or cygdrive drive root (`/c`, `/mnt/c`, `/cygdrive/c`), and a UNC share root (`//server/share`). `--no-preserve-root` is refused whatever it targets. Also refused: an **empty operand** (`rm -rf ""`), a **bare variable operand** made of expansions alone (`$X`, `"$X/"`, `"$X"/*`, `$X$Y`, any `${...}` form but `"${X:?}/"`), and, when the payload carries an absolute `cwd`, a target **outside the session's allowed roots**: not under the git toplevel of the payload cwd, and not strictly under a temp root or the session scratchpad (`rm -rf ../../..`, `rm -rf ~/Documents/x`, `cd / && rm -rf *`, `rm -rf {/c,x}`, `rm -rf /c/Users/*/.claude`, a `link/` that points outside). Not host-gated. The guard's header in `hooks/block-root-delete-target.sh` lists how braces, globs, directory changes and symlinks are judged, the bounds past which it refuses, the known false positives, and the declared gaps, among them PowerShell `Remove-Item -Recurse` ([#4516](https://github.com/melodic-software/claude-code-plugins/issues/4516)). | | **cli-flag-verify** | PostToolUse · Write \| Edit, dispatcher `if`-gated to `.md`, `.sh`, `.bash`, `.ps1`, `.psm1` | **Advisory** (exit 0) | Hallucinated CLI flags: a `--flag` written as a command that does not exist in the binary's actual `--help` output. Surfaces via `additionalContext`, never blocks. | @@ -1303,7 +1303,7 @@ reads it from. | `block_no_verify_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_NO_VERIFY_ENABLED` | Block git hook-bypass attempts (--no-verify, core.hooksPath=, hook-manager env-var disables for a configurable set: lefthook/husky/pre-commit/simple-git-hooks by default) | | `block_dangerous_git_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ENABLED` | Block irreversible git operations (push --force, reset --hard, clean -f, worktree-wide checkout/restore discards, and push --force-with-lease when it leases against a value git resolves at push time, meaning either no expected value, or an expectation that is not an object id of the repository's own hash width) | | `block_hook_bypass_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED` | Block Bash file-write workarounds that circumvent Write/Edit hook gates | -| `block_windows_drive_tmp_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED` | Block writes whose target is a Windows drive-root temp path (/tmp, C:\tmp, \tmp, /c/tmp) that resolves to :\tmp instead of %TEMP%, in both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes | +| `block_windows_drive_tmp_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED` | Block writes whose target is a Windows drive-root temp path (/tmp, C:\tmp, \tmp, /c/tmp) that resolves to :\tmp instead of %TEMP%, in both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes. On Git for Windows, a Bash-tool /tmp that cygpath/mount shows is the usertemp mount of %TEMP% is not blocked; /c/tmp, C:\tmp, drive-root \tmp, PowerShell /tmp, and the file-path lane still are. curl -o/--output and wget -O/--output-document destinations are judged the same way as cp/mv | | `block_exported_msys_pathconv_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_EXPORTED_MSYS_PATHCONV_ENABLED` | Block a leaking MSYS path-conversion suppressor on Windows: an EXPORTED MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL, or a prefix on a child shell (MSYS_NO_PATHCONV=1 bash -c ...). Either switches off conversion for later commands, letting an unconverted /d/... reach git as :\d\...; a prefix on a non-shell command word and a bare assignment are not matched | | `block_root_delete_target_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_ROOT_DELETE_TARGET_ENABLED` | Block a Bash recursive `rm` whose target normalizes to a filesystem root: `/` and `/*`, the MSYS-translated bare backslash (`rm -rf "\\"` and a dangling `rm -rf \`, the shape that cost a whole volume in anthropics/claude-code#92593), `~`, a literal `$HOME` / `${HOME}`, a drive root (`C:\`, `c:/`, `C:`), an MSYS, WSL or cygdrive drive root (`/c`, `/mnt/c`, `/cygdrive/c`), and a UNC share root (`//server/share`). A recursive `rm` carrying `--no-preserve-root` is refused whatever it targets, and long options are matched on any unambiguous prefix as coreutils reads them. The command word is resolved through a launcher and its operand-taking options (`sudo -u bob rm`), and a child shell's operand is re-parsed (`bash -c '...'`). Quoted prose that merely names such a command is not matched, because the command word of that segment is not `rm`. It also refuses an empty operand (`rm -rf ""`), a bare variable operand (`$X`, `"$X/"`, `"$X"/*`, `$X$Y`, any `${...}` form but `"${X:?}/"`), and, when the payload carries a cwd, a target that resolves outside the payload cwd's git toplevel and is not strictly under a temp root or the session scratchpad (`rm -rf ../../..`, `rm -rf ~/Documents/x`, `cd / && rm -rf *`, `rm -rf /c/Users/*/.claude`, a `link/` that points outside). Braces are expanded and each alternative judged (`rm -rf {/c,x}`), a glob before the last component is expanded and each match judged as a literal path, and a relative path after a cd it can read but not follow (a relative cd while CDPATH is set, a glob target with no match or several) is refused. The judgment is bounded (512 targets, 256 glob entries, 25 seconds of wall time) and refuses past a bound. A target it cannot place (an expansion other than HOME, a relative path after a non-literal cd) is left alone. PowerShell `Remove-Item -Recurse` is not covered (issue #4516) | | `block_noncanonical_commit_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ENABLED` | Block `git commit -m` when the message actually contains a newline (multi-line `-m` mangles across shells, so pipe it via `-F -` instead; single-line `-m` passes); --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt | diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 4d6fe5fb73..27815067d0 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -14,10 +14,12 @@ # # On Windows (Git Bash / MSYS / Cygwin), a hardcoded POSIX `/tmp` path resolves to # `:\tmp` (e.g. `C:\tmp`) rather than the platform temp directory -# (`%TEMP%` — typically `C:\Users\\AppData\Local\Temp`). Drive-letter and -# drive-relative spellings (`C:\tmp`, `/c/tmp`, `\tmp`) are the same sink. Nothing -# else in the guard surface noticed those writes, so residue accumulated silently -# at the volume root (#2594). +# (`%TEMP%` — typically `C:\Users\\AppData\Local\Temp`) — EXCEPT on a stock +# Git for Windows install, where `/tmp` is a `usertemp` mount of `%TEMP%` itself +# (`cygpath -w /tmp` answers the same directory). Blocking a Bash-tool write to +# that `/tmp` is a false positive (#4251). Drive-letter and drive-relative +# spellings (`C:\tmp`, `/c/tmp`, `\tmp`) stay the volume-root sink on every lane. +# PowerShell has no MSYS mount table, so its `/tmp` spelling stays blocked. # # This guard FAILS CLOSED on a write-shaped reference to those roots and points # the operator at the platform temp. It does NOT engage on non-Windows hosts @@ -169,6 +171,51 @@ fi COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" + +# Cached: 0 = POSIX /tmp is this process's user temp, 1 = not (or unknown). +# One probe per hook process. Git for Windows stock /tmp is a usertemp mount +# of %TEMP% (#4251). +_DRIVE_TMP_POSIX_USERTEMP="" +posix_tmp_maps_to_usertemp() { + if [[ -n "$_DRIVE_TMP_POSIX_USERTEMP" ]]; then + return "$_DRIVE_TMP_POSIX_USERTEMP" + fi + _DRIVE_TMP_POSIX_USERTEMP=1 + local tmp_win="" temp_win="" temp_env mount_line tmp_n temp_n + temp_env="${TEMP:-${TMP:-}}" + if command -v cygpath >/dev/null 2>&1 && [[ -n "$temp_env" ]]; then + tmp_win=$(cygpath -w /tmp 2>/dev/null) || tmp_win="" + if [[ -n "$tmp_win" ]]; then + temp_win=$(cygpath -w "$temp_env" 2>/dev/null) || temp_win="$temp_env" + tmp_n="${tmp_win,,}" + tmp_n="${tmp_n//\\//}" + tmp_n="${tmp_n%/}" + temp_n="${temp_win,,}" + temp_n="${temp_n//\\//}" + temp_n="${temp_n%/}" + if [[ -n "$tmp_n" && -n "$temp_n" && ( "$tmp_n" == "$temp_n" || "$tmp_n" == "$temp_n"/* ) ]]; then + _DRIVE_TMP_POSIX_USERTEMP=0 + return 0 + fi + fi + fi + # Git for Windows mount table: "... on /tmp type ntfs (...,usertemp)". + # Linux CI's /tmp tmpfs line has no usertemp flag, so forcing OSTYPE=msys + # there does not trip this arm. + mount_line=$(mount 2>/dev/null) || mount_line="" + if [[ "$mount_line" == *" on /tmp "* && "$mount_line" == *"usertemp"* ]]; then + _DRIVE_TMP_POSIX_USERTEMP=0 + return 0 + fi + return 1 +} + +# Bash-lane only: when POSIX /tmp already is %TEMP%, skip the /tmp arm. +# File-path and PowerShell lanes keep matching /tmp (no MSYS mount table). +_DRIVE_TMP_SKIP_POSIX=0 +if [[ "$TOOL_NAME" == "Bash" ]] && posix_tmp_maps_to_usertemp; then + _DRIVE_TMP_SKIP_POSIX=1 +fi # Write / Edit / MultiEdit spell the target `file_path`; NotebookEdit spells it # `notebook_path`. Reading both and taking whichever is populated keeps the lane # correct without depending on which spelling a given tool version emits. @@ -237,8 +284,51 @@ norm_lower() { norm_lower "$COMMAND" NORM="$NORM_OUT" +# Cached: 0 = POSIX /tmp is this process's user temp, 1 = not (or unknown). +# One probe per hook process; Git for Windows stock /tmp is a `usertemp` mount +# of %TEMP% (#4251). +_DRIVE_TMP_POSIX_USERTEMP="" +_DRIVE_TMP_SKIP_POSIX=0 + +posix_tmp_maps_to_usertemp() { + if [[ -n "$_DRIVE_TMP_POSIX_USERTEMP" ]]; then + return "$_DRIVE_TMP_POSIX_USERTEMP" + fi + _DRIVE_TMP_POSIX_USERTEMP=1 + local tmp_win="" temp_win="" temp_env mount_line tmp_n temp_n + temp_env="${TEMP:-${TMP:-}}" + if command -v cygpath >/dev/null 2>&1 && [[ -n "$temp_env" ]]; then + tmp_win=$(cygpath -w /tmp 2>/dev/null) || tmp_win="" + if [[ -n "$tmp_win" ]]; then + temp_win=$(cygpath -w "$temp_env" 2>/dev/null) || temp_win="$temp_env" + tmp_n="${tmp_win,,}" + tmp_n="${tmp_n//\\//}" + tmp_n="${tmp_n%/}" + temp_n="${temp_win,,}" + temp_n="${temp_n//\\//}" + temp_n="${temp_n%/}" + if [[ -n "$tmp_n" && -n "$temp_n" && ( "$tmp_n" == "$temp_n" || "$tmp_n" == "$temp_n"/* ) ]]; then + _DRIVE_TMP_POSIX_USERTEMP=0 + return 0 + fi + fi + fi + # Git for Windows mount table: "... on /tmp type ntfs (...,usertemp)". The + # usertemp flag is what distinguishes that mount from a volume-root /tmp. + # Linux CI's /tmp tmpfs line has no such flag, so forcing OSTYPE=msys there + # does not trip this arm. + mount_line=$(mount 2>/dev/null) || mount_line="" + if [[ "$mount_line" == *" on /tmp "* && "$mount_line" == *"usertemp"* ]]; then + _DRIVE_TMP_POSIX_USERTEMP=0 + return 0 + fi + return 1 +} + # True when carries a drive-root tmp path reference: -# /tmp[/...] — POSIX form (Git Bash maps this to :\tmp) +# /tmp[/...] — POSIX form (Git Bash maps this to :\tmp, +# unless _DRIVE_TMP_SKIP_POSIX: Bash-tool /tmp that +# already is %TEMP%) # /x/tmp[/...] — MSYS drive form (/c/tmp → C:\tmp) # x:/tmp[/...] — Windows drive-letter form # Left boundary excludes a relative `./tmp` and a `/var/tmp` suffix (the char @@ -246,7 +336,8 @@ NORM="$NORM_OUT" has_drive_root_tmp() { local s="$1" # POSIX /tmp — not ./tmp, not /var/tmp, not /tmpdir - if [[ "$s" =~ (^|[^[:alnum:]._/])\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then + if ((_DRIVE_TMP_SKIP_POSIX == 0)) && + [[ "$s" =~ (^|[^[:alnum:]._/])\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 fi # MSYS //tmp, in two arms because a `:` on the left is ambiguous and @@ -335,7 +426,11 @@ has_redirect_to_drive_root_tmp() { # literal characters (not GNU \< \> word-boundaries) — keep them unescaped so # the portability gate does not flag this ERE. portability-ok: bash [[ =~ ]] # character class literals, not grep -E word boundaries - if [[ "$s" =~ (^|[^>&])\&?[0-9]*\>\>?[[:space:]]*[\"\']?(\/tmp|\/[a-z]\/tmp|[a-z]:\/tmp)(\/|[\"\'[:space:]\;|&<>()]|$) ]]; then + if [[ "$s" =~ (^|[^>&])\&?[0-9]*\>\>?[[:space:]]*[\"\']?(\/[a-z]\/tmp|[a-z]:\/tmp)(\/|[\"\'[:space:]\;|&<>()]|$) ]]; then + return 0 + fi + if ((_DRIVE_TMP_SKIP_POSIX == 0)) && + [[ "$s" =~ (^|[^>&])\&?[0-9]*\>\>?[[:space:]]*[\"\']?\/tmp(\/|[\"\'[:space:]\;|&<>()]|$) ]]; then return 0 fi return 1 @@ -426,6 +521,43 @@ segment_destination_operand() { printf '%s' "$dest" } +# curl -o/--output and wget -O/--output-document write targets. Glued +# (`-o/tmp/x`) and `--flag=path` forms count; a URL that merely contains +# `/tmp` does not. +segment_downloader_output_operand() { + local subject="$1" tok dest="" expect=0 + local -a tokens=() + # Intentional word-split of the static matcher subject into tokens. + # shellcheck disable=SC2206 + tokens=( $subject ) + for tok in "${tokens[@]}"; do + tok="${tok#\'}" + tok="${tok%\'}" + tok="${tok#\"}" + tok="${tok%\"}" + if ((expect)); then + dest="$tok" + expect=0 + continue + fi + case "$tok" in + --output=* | --output-document=*) + dest="${tok#*=}" + ;; + --output | --output-document | -O | -o) + expect=1 + ;; + -o?*) + dest="${tok#-o}" + ;; + -O?*) + dest="${tok#-O}" + ;; + esac + done + printf '%s' "$dest" +} + # True when a segment's destination-shaped operand is a drive-root tmp path. # Creators (mkdir/touch/…) treat any drive-root path argument as a write; # copy/move utilities bind only the destination operand. @@ -454,10 +586,18 @@ segment_writes_drive_root_tmp() { has_drive_root_tmp "$dest" && return 0 return 1 fi + # curl / wget: output-flag operands only (#4251). A URL path containing + # `/tmp` is not a write target. + if [[ "$subject" =~ (^|[[:space:];|&]|/)(curl|wget|curl\.exe|wget\.exe)([[:space:]]|$) ]]; then + dest=$(segment_downloader_output_operand "$subject") + [[ -n "$dest" ]] || return 1 + has_drive_root_tmp "$dest" && return 0 + return 1 + fi # Inline python write opening a drive-root tmp path - if [[ "$subject" =~ (open|write_text|write_bytes|makedirs)\( ]] && - [[ "$subject" =~ (\/tmp|\/[a-z]\/tmp|[a-z]:\/tmp) ]]; then - return 0 + if [[ "$subject" =~ (open|write_text|write_bytes|makedirs)\( ]]; then + has_drive_root_tmp "$subject" && return 0 + return 1 fi return 1 } @@ -468,7 +608,9 @@ segment_writes_drive_root_tmp() { # inspected per segment so `mkdir ./out && cat /tmp/src` stays allowed. has_write_utility_with_drive_root_tmp() { local s="$1" piece - has_drive_root_tmp "$s" || return 1 + # Whole-string matcher is the cheap reject. A glued dest flag (`curl -o/tmp/x`) + # hides `/tmp` behind an alphanumeric, so also walk when the letters appear. + has_drive_root_tmp "$s" || [[ "$s" == *'/tmp'* ]] || return 1 while IFS= read -r -d '' piece; do [[ -n "${piece//[[:space:]]/}" ]] || continue if segment_writes_drive_root_tmp "$piece"; then @@ -498,6 +640,13 @@ fi # the command character by character, and scanning an empty string would be # per-Write budget spent to reach a foregone `no`. if [[ -n "$COMMAND" ]]; then + # Bash-tool POSIX /tmp on a Git for Windows usertemp mount already lands in + # %TEMP%. Skip that spelling only; /c/tmp, C:\tmp and drive-root \tmp stay + # blocked, and PowerShell is unchanged (#4251). + _DRIVE_TMP_SKIP_POSIX=0 + if [[ "$TOOL_NAME" == "Bash" ]] && posix_tmp_maps_to_usertemp; then + _DRIVE_TMP_SKIP_POSIX=1 + fi if has_redirect_to_drive_root_tmp "$NORM"; then block "redirect" fi diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 4bedf0b64e..2a371900fe 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -316,6 +316,52 @@ run_win "single-quoted prose redirect (allowed)" "printf '%s' 'echo > /tmp/x'" 0 run_win "cp from /tmp (allowed)" 'cp /tmp/source ./dest' 0 run_win "compound mkdir then cat /tmp (allowed)" 'mkdir ./out && cat /tmp/source' 0 +# --- Downloaders: curl -o / wget -O destinations (#4251) --------------------- +# These were allowed while mkdir/cp/redirect of the same path blocked. +run_win "curl -o /tmp/x (blocked)" 'curl -sS -o /tmp/x https://example.com' 2 +run_win "curl --output /tmp/x (blocked)" 'curl --output /tmp/x https://example.com' 2 +run_win "curl --output=/tmp/x (blocked)" 'curl --output=/tmp/x https://example.com' 2 +run_win "curl glued -o/tmp/x (blocked)" 'curl -o/tmp/x https://example.com' 2 +run_win "wget -O /tmp/a.html (blocked)" 'wget -O /tmp/a.html https://example.com' 2 +run_win "wget --output-document /tmp/a.html (blocked)" \ + 'wget --output-document /tmp/a.html https://example.com' 2 +run_win "curl -o /c/tmp/x (blocked)" 'curl -o /c/tmp/x https://example.com' 2 +run_win "curl https://example.com (allowed — no dest flag)" 'curl -sS https://example.com' 0 +run_win "curl -o ./out.html (allowed)" 'curl -o ./out.html https://example.com' 0 +run_win "curl URL containing /tmp (allowed — URL is not dest)" \ + 'curl -sS https://example.com/tmp/hooks.md' 0 + +# --- Git for Windows usertemp /tmp is the platform temp (#4251) -------------- +# Stub cygpath so POSIX /tmp compares equal to %TEMP%, the stock Git for +# Windows mount. Linux CI's real /tmp is tmpfs without usertemp, so without +# the stub the existing rows above still block. +USERTEMP_STUB="$TEST_TMPDIR/cygpath-stub" +mkdir -p "$USERTEMP_STUB" +cat >"$USERTEMP_STUB/cygpath" <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "-w" ]]; then + shift + if [[ "$1" == "/tmp" ]]; then + printf '%s\n' "${TEMP:-C:\\Users\\user\\AppData\\Local\\Temp}" + else + printf '%s\n' "$1" + fi + exit 0 +fi +exit 1 +EOF +chmod +x "$USERTEMP_STUB/cygpath" +USERTEMP_ENV=(PATH="$USERTEMP_STUB:$PATH" TEMP='C:\Users\user\AppData\Local\Temp') +run_win "usertemp: mkdir /tmp/x (allowed)" 'mkdir -p /tmp/x' 0 "${USERTEMP_ENV[@]}" +run_win "usertemp: redirect >/tmp/x (allowed)" 'echo x > /tmp/x' 0 "${USERTEMP_ENV[@]}" +run_win "usertemp: curl -o /tmp/x (allowed)" \ + 'curl -sS -o /tmp/x https://example.com' 0 "${USERTEMP_ENV[@]}" +run_win "usertemp: mkdir /c/tmp/x still blocked" 'mkdir -p /c/tmp/x' 2 "${USERTEMP_ENV[@]}" +run_win "usertemp: mkdir C:\\tmp\\x still blocked" 'mkdir -p C:\tmp\x' 2 "${USERTEMP_ENV[@]}" +run_win_pwsh "usertemp: PS /tmp still blocked" "'hi' > /tmp/x" 2 "${USERTEMP_ENV[@]}" +run_win_payload "usertemp: Write /tmp/x still blocked" "$(write_json '/tmp/x' 'x')" 2 \ + "${USERTEMP_ENV[@]}" + # --- PowerShell copy/move destinations (blocked) ----------------------------- run_win_pwsh "PS: Copy-Item to C:\\tmp (blocked)" 'Copy-Item .\a C:\tmp\a' 2 run_win_pwsh "PS: Move-Item to C:\\tmp (blocked)" 'Move-Item .\a C:\tmp\a' 2 From 470f3d20c09a88600ce2a753e60fc359ab08615c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 03:19:15 +0000 Subject: [PATCH 2/3] chore(guardrails): serialize #4251 to 0.39.2; drop duplicate usertemp probe Version sits above in-flight 0.39.1 (#4252). The usertemp probe lives once, ahead of the matchers. The cygpath stub uses the placeholder so the machine-specific-paths gate stays quiet. Co-authored-by: Kyle Sexton --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- .../hooks/block-windows-drive-tmp.sh | 44 ------------------- .../hooks/block-windows-drive-tmp.test.sh | 4 +- 3 files changed, 3 insertions(+), 47 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 1b4d31c813..37c741fc5f 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -153,5 +153,5 @@ "min": 1 } }, - "version": "0.38.13" + "version": "0.39.2" } diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 27815067d0..d21ad27c40 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -172,50 +172,6 @@ fi COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" -# Cached: 0 = POSIX /tmp is this process's user temp, 1 = not (or unknown). -# One probe per hook process. Git for Windows stock /tmp is a usertemp mount -# of %TEMP% (#4251). -_DRIVE_TMP_POSIX_USERTEMP="" -posix_tmp_maps_to_usertemp() { - if [[ -n "$_DRIVE_TMP_POSIX_USERTEMP" ]]; then - return "$_DRIVE_TMP_POSIX_USERTEMP" - fi - _DRIVE_TMP_POSIX_USERTEMP=1 - local tmp_win="" temp_win="" temp_env mount_line tmp_n temp_n - temp_env="${TEMP:-${TMP:-}}" - if command -v cygpath >/dev/null 2>&1 && [[ -n "$temp_env" ]]; then - tmp_win=$(cygpath -w /tmp 2>/dev/null) || tmp_win="" - if [[ -n "$tmp_win" ]]; then - temp_win=$(cygpath -w "$temp_env" 2>/dev/null) || temp_win="$temp_env" - tmp_n="${tmp_win,,}" - tmp_n="${tmp_n//\\//}" - tmp_n="${tmp_n%/}" - temp_n="${temp_win,,}" - temp_n="${temp_n//\\//}" - temp_n="${temp_n%/}" - if [[ -n "$tmp_n" && -n "$temp_n" && ( "$tmp_n" == "$temp_n" || "$tmp_n" == "$temp_n"/* ) ]]; then - _DRIVE_TMP_POSIX_USERTEMP=0 - return 0 - fi - fi - fi - # Git for Windows mount table: "... on /tmp type ntfs (...,usertemp)". - # Linux CI's /tmp tmpfs line has no usertemp flag, so forcing OSTYPE=msys - # there does not trip this arm. - mount_line=$(mount 2>/dev/null) || mount_line="" - if [[ "$mount_line" == *" on /tmp "* && "$mount_line" == *"usertemp"* ]]; then - _DRIVE_TMP_POSIX_USERTEMP=0 - return 0 - fi - return 1 -} - -# Bash-lane only: when POSIX /tmp already is %TEMP%, skip the /tmp arm. -# File-path and PowerShell lanes keep matching /tmp (no MSYS mount table). -_DRIVE_TMP_SKIP_POSIX=0 -if [[ "$TOOL_NAME" == "Bash" ]] && posix_tmp_maps_to_usertemp; then - _DRIVE_TMP_SKIP_POSIX=1 -fi # Write / Edit / MultiEdit spell the target `file_path`; NotebookEdit spells it # `notebook_path`. Reading both and taking whichever is populated keeps the lane # correct without depending on which spelling a given tool version emits. diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 2a371900fe..bc8ef9a515 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -342,7 +342,7 @@ cat >"$USERTEMP_STUB/cygpath" <<'EOF' if [[ "$1" == "-w" ]]; then shift if [[ "$1" == "/tmp" ]]; then - printf '%s\n' "${TEMP:-C:\\Users\\user\\AppData\\Local\\Temp}" + printf '%s\n' "${TEMP:-C:\\Users\\\\AppData\\Local\\Temp}" else printf '%s\n' "$1" fi @@ -351,7 +351,7 @@ fi exit 1 EOF chmod +x "$USERTEMP_STUB/cygpath" -USERTEMP_ENV=(PATH="$USERTEMP_STUB:$PATH" TEMP='C:\Users\user\AppData\Local\Temp') +USERTEMP_ENV=(PATH="$USERTEMP_STUB:$PATH" TEMP='C:\Users\\AppData\Local\Temp') run_win "usertemp: mkdir /tmp/x (allowed)" 'mkdir -p /tmp/x' 0 "${USERTEMP_ENV[@]}" run_win "usertemp: redirect >/tmp/x (allowed)" 'echo x > /tmp/x' 0 "${USERTEMP_ENV[@]}" run_win "usertemp: curl -o /tmp/x (allowed)" \ From 3d7c426472c3a1dc80e688f8f8cf5459e6d56ed1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Sep 2026 03:42:16 +0000 Subject: [PATCH 3/3] fix(guardrails): default the downloader case and excuse path regexes Shellcheck wants an explicit fallthrough arm, and the portability gate flags the angle brackets that are character-class literals. Co-authored-by: Kyle Sexton --- plugins/guardrails/hooks/block-windows-drive-tmp.sh | 3 +++ plugins/guardrails/hooks/block-windows-drive-tmp.test.sh | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index d21ad27c40..a9ab4ad06c 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -386,6 +386,7 @@ has_redirect_to_drive_root_tmp() { return 0 fi if ((_DRIVE_TMP_SKIP_POSIX == 0)) && + # portability-ok: character-class angle brackets in a bash regex, not a GNU word boundary [[ "$s" =~ (^|[^>&])\&?[0-9]*\>\>?[[:space:]]*[\"\']?\/tmp(\/|[\"\'[:space:]\;|&<>()]|$) ]]; then return 0 fi @@ -509,6 +510,8 @@ segment_downloader_output_operand() { -O?*) dest="${tok#-O}" ;; + *) + ;; esac done printf '%s' "$dest" diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index bc8ef9a515..a380ed6679 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -342,6 +342,7 @@ cat >"$USERTEMP_STUB/cygpath" <<'EOF' if [[ "$1" == "-w" ]]; then shift if [[ "$1" == "/tmp" ]]; then + # portability-ok: placeholder Windows profile in the cygpath stub, not a redirection printf '%s\n' "${TEMP:-C:\\Users\\\\AppData\\Local\\Temp}" else printf '%s\n' "$1" @@ -351,7 +352,7 @@ fi exit 1 EOF chmod +x "$USERTEMP_STUB/cygpath" -USERTEMP_ENV=(PATH="$USERTEMP_STUB:$PATH" TEMP='C:\Users\\AppData\Local\Temp') +USERTEMP_ENV=(PATH="$USERTEMP_STUB:$PATH" TEMP='C:\Users\\AppData\Local\Temp') # portability-ok: placeholder Windows profile, not a redirection run_win "usertemp: mkdir /tmp/x (allowed)" 'mkdir -p /tmp/x' 0 "${USERTEMP_ENV[@]}" run_win "usertemp: redirect >/tmp/x (allowed)" 'echo x > /tmp/x' 0 "${USERTEMP_ENV[@]}" run_win "usertemp: curl -o /tmp/x (allowed)" \