Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion global/guards/protect-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions global/settings.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@
"Bash(rm -rf ~:*)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(curl:* | sh)",
"Bash(curl:* | bash)",
"Bash(sudo:*)"
]
},
Expand Down
50 changes: 50 additions & 0 deletions test/settings_template.test.js
Original file line number Diff line number Diff line change
@@ -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(<spec>)` 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");
});
Loading