diff --git a/CHANGELOG.md b/CHANGELOG.md index abbcef8..929fda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- security: drop two inert `curl`-pipe deny rules from the settings template. + Claude Code only honors `:*` as a trailing wildcard, so the trailing pipe made + the colon a literal and the rules matched nothing. Real pipe-to-shell + enforcement already lives in `protect-paths.sh`, now tightened to also catch a + no-space pipe and a `zsh` target. Adds a regression test for template rule + shape. + ## [0.12.3] - 2026-07-11 ### Fixed diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index bd116a9..b7366ff 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -38,8 +38,11 @@ if [ -n "${cmd:-}" ]; then *"rm -rf /"*|*"rm -rf ~"*|*"rm -rf --no-preserve-root"*) deny "destructive rm detected." ;; *"git push --force"*|*"git push -f"*) deny "force-push blocked. Ask the user first." ;; *"DROP TABLE"*|*"DROP DATABASE"*|*"TRUNCATE "*) deny "destructive SQL detected. Confirm with the user." ;; - *" | sh"*|*" | bash"*) deny "piping remote content to a shell is blocked." ;; esac + # Pipe-to-shell (e.g. curl … | sh). Boundary-aware so legit `… | shellcheck` is not caught. + if [[ "$cmd" =~ \|[[:space:]]*(sh|bash|zsh)([[:space:]]|$) ]]; then + deny "piping content to a shell is blocked." + fi fi exit 0 diff --git a/global/settings.template.json b/global/settings.template.json index 6feb964..5b2f542 100644 --- a/global/settings.template.json +++ b/global/settings.template.json @@ -70,8 +70,6 @@ "Bash(rm -rf ~:*)", "Bash(git push --force:*)", "Bash(git push -f:*)", - "Bash(curl:* | sh)", - "Bash(curl:* | bash)", "Bash(sudo:*)" ] }, diff --git a/test/settings_template.test.js b/test/settings_template.test.js new file mode 100644 index 0000000..6a6f3d9 --- /dev/null +++ b/test/settings_template.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const templateUrl = new URL("../global/settings.template.json", import.meta.url); +const template = JSON.parse(readFileSync(templateUrl, "utf8")); + +/** Specifier inside a `Bash()` rule, or null when the rule isn't a Bash rule. */ +function bashSpec(rule) { + const match = /^Bash\((.*)\)$/.exec(rule); + return match ? match[1] : null; +} + +const rules = [ + ...(template.permissions?.allow ?? []), + ...(template.permissions?.ask ?? []), + ...(template.permissions?.deny ?? []), +]; + +test("settings template parses and has a permissions block", () => { + assert.ok(template.permissions, "permissions block present"); +}); + +test("no Bash rule embeds a shell compound operator in its specifier", () => { + // Claude Code splits compound commands on `|`, `&&`, `;` and matches each + // subcommand independently, so an operator inside a single specifier can never + // match a real command. This is the exact shape of the old inert + // `Bash(curl:* | sh)` rules — keep it out for good. + for (const rule of rules) { + const spec = bashSpec(rule); + if (spec === null) continue; + assert.doesNotMatch(spec, /\||&&|;/, `malformed Bash rule (compound operator): ${rule}`); + } +}); + +test("`:*` appears only at the end of a Bash specifier", () => { + // `:*` is a trailing-wildcard shorthand ONLY at the end of a pattern; anywhere + // else the colon is a literal character and the rule matches nothing. + for (const rule of rules) { + const spec = bashSpec(rule); + if (spec === null || !spec.includes(":*")) continue; + assert.ok(spec.endsWith(":*"), `\`:*\` not at end of specifier: ${rule}`); + } +}); + +test("the former inert curl-pipe deny rules are absent", () => { + const deny = template.permissions?.deny ?? []; + assert.ok(!deny.includes("Bash(curl:* | sh)"), "inert curl|sh rule must stay removed"); + assert.ok(!deny.includes("Bash(curl:* | bash)"), "inert curl|bash rule must stay removed"); +});