diff --git a/config/claude/security.sh b/config/claude/security.sh index 40bc3dddc..3202605d6 100644 --- a/config/claude/security.sh +++ b/config/claude/security.sh @@ -37,8 +37,10 @@ matches_pattern() { # Extract pattern from Bash(...) format if [[ $pattern =~ ^Bash\((.+)\)$ ]]; then local check_pattern="${BASH_REMATCH[1]}" - # Remove trailing :* if present (legacy format) - check_pattern="${check_pattern%:*}" + # Convert legacy trailing :* into a prefix glob (e.g. sudo:* -> sudo*) + if [[ $check_pattern == *':*' ]]; then + check_pattern="${check_pattern%:*}*" + fi # Use bash glob matching (extended globbing) shopt -s extglob @@ -53,8 +55,7 @@ matches_pattern() { # Split command at logical operators to catch hidden dangerous commands # This handles: cmd1 ; cmd2, cmd1 && cmd2, cmd1 || cmd2, cmd1 | cmd2 -# shellcheck disable=SC2001 -IFS=$'\n' read -r -d '' -a segments < <(echo "$command" | sed 's/[;&|]\+/\n/g' && printf '\0') || true +IFS=$'\n' read -r -d '' -a segments < <(echo "$command" | sed -E 's/[;&|]+/\n/g' && printf '\0') || true for segment in "${segments[@]}"; do # Trim leading/trailing whitespace diff --git a/spec/notify_spec.sh b/spec/notify_spec.sh index 7775a0c7a..bb3a66e96 100644 --- a/spec/notify_spec.sh +++ b/spec/notify_spec.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2329 +# shellcheck disable=SC2329,SC2016 Describe 'notify.sh' SCRIPT="$PWD/config/claude/notify.sh" @@ -18,20 +18,41 @@ The output should eq '' End End +Describe 'Pushover configured with .env present' +setup() { + mock_bin_setup osascript + TEMP_HOME=$(mktemp -d) + mkdir -p "$TEMP_HOME/dotfiles" + cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +touch "$HOME/notify_sourced_marker" +PUSHOVER_API_TOKEN=bad_token +PUSHOVER_USER_KEY=bad_user +ENV +} +cleanup() { + rm -rf "$TEMP_HOME" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'does not source HOME/dotfiles/.env when credentials already set' +When run bash -c 'echo "{\"message\": \"Test message\"}" | env HOME="'"$TEMP_HOME"'" PUSHOVER_API_TOKEN="test_token" PUSHOVER_USER_KEY="test_user" bash '"$SCRIPT"'; test ! -f "'"$TEMP_HOME"'/notify_sourced_marker"; cat "$MOCK_LOG"' +The status should be success +The output should eq '' +End +End + Describe 'Notification hook (no Pushover)' setup() { - # Create mock osascript that does nothing - MOCK_BIN=$(mktemp -d) - printf '#!/bin/sh\nexit 0\n' >"$MOCK_BIN/osascript" - chmod +x "$MOCK_BIN/osascript" - export PATH="$MOCK_BIN:$PATH" + mock_bin_setup osascript # Unset Pushover credentials to ensure clean test environment unset PUSHOVER_API_TOKEN unset PUSHOVER_USER_KEY } cleanup() { - rm -rf "$MOCK_BIN" + mock_bin_cleanup } Before 'setup' After 'cleanup' @@ -47,22 +68,96 @@ It 'exits 0 for waiting notification' When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | env HOME=/nonexistent bash '"$SCRIPT" The status should be success End + +It 'sends Basso notification for permission request' +When run bash -c 'echo "{\"message\": \"Claude needs your permission to use Bash\"}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Bash permission required' +The output should include 'sound name "Basso"' +End +End + +Describe 'when credentials come from HOME/dotfiles/.env' +setup() { + mock_bin_setup osascript + TEMP_HOME=$(mktemp -d) + mkdir -p "$TEMP_HOME/dotfiles" + cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_API_TOKEN=test_token +PUSHOVER_USER_KEY=test_user +ENV + + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$TEMP_HOME" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'exits early and does not send local notification' +When run bash -c 'echo "{\"message\": \"Test message\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should eq '' +End +End + +Describe 'credential sourcing edge cases' +setup() { + mock_bin_setup osascript + TEMP_HOME=$(mktemp -d) + mkdir -p "$TEMP_HOME/dotfiles" + + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$TEMP_HOME" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'sources .env when only one credential is set' +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_USER_KEY=from_env +ENV +When run bash -c 'echo "{\"message\": \"Test message\"}" | env HOME="'"$TEMP_HOME"'" PUSHOVER_API_TOKEN="present" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should eq '' +End + +It 'sends local notification when .env is incomplete' +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_API_TOKEN=only_token +ENV +When run bash -c 'echo "{\"message\": \"Hello\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'display notification' +End + +It 'ignores .env stderr and still sends local notification on source error' +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +this is not valid bash +ENV +When run bash -c 'echo "{\"message\": \"Hello\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'display notification' +End End Describe 'SessionEnd hook (no Pushover)' setup() { - # Create mock osascript that does nothing - MOCK_BIN=$(mktemp -d) - printf '#!/bin/sh\nexit 0\n' >"$MOCK_BIN/osascript" - chmod +x "$MOCK_BIN/osascript" - export PATH="$MOCK_BIN:$PATH" + mock_bin_setup osascript # Unset Pushover credentials to ensure clean test environment unset PUSHOVER_API_TOKEN unset PUSHOVER_USER_KEY } cleanup() { - rm -rf "$MOCK_BIN" + mock_bin_cleanup } Before 'setup' After 'cleanup' @@ -73,4 +168,51 @@ When run bash -c 'echo "{\"reason\": \"user_exit\"}" | env HOME=/nonexistent bas The status should be success End End + +Describe 'other hooks (no Pushover)' +setup() { + mock_bin_setup osascript + TEMP_HOME=$(mktemp -d) + + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$TEMP_HOME" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'notifies on PreCompact auto trigger' +When run bash -c 'echo "{\"trigger\": \"auto\"}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Auto-compacting context' +End + +It 'notifies on SubagentStop' +When run bash -c 'echo "{\"stop_hook_active\": true}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Subagent task completed' +End + +It 'notifies on Stop with cwd and session id' +When run bash -c 'echo "{\"cwd\": \"'"$TEMP_HOME"'/work\", \"session_id\": \"abcdef0123456789\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Work completed in ~/work (abcdef01)' +End + +It 'warns on risky PreToolUse Bash command' +When run bash -c 'echo "{\"tool\": {\"name\": \"Bash\", \"input\": \"rm -rf /\"}}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Risky: rm -rf /' +The output should include 'sound name "Basso"' +End + +It 'does not warn on PreToolUse non-Bash tool' +When run bash -c 'echo "{\"tool\": {\"name\": \"Read\", \"input\": {}}}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should eq '' +End +End End diff --git a/spec/pushover_spec.sh b/spec/pushover_spec.sh index 9786a3d95..de3504472 100644 --- a/spec/pushover_spec.sh +++ b/spec/pushover_spec.sh @@ -1,23 +1,19 @@ #!/usr/bin/env bash -# shellcheck disable=SC2329 +# shellcheck disable=SC2329,SC2016 Describe 'pushover.sh' SCRIPT="$PWD/config/claude/pushover.sh" Describe 'credential handling' setup() { - # Create a mock curl in case credentials leak through - MOCK_BIN=$(mktemp -d) - printf '#!/bin/sh\nexit 0\n' >"$MOCK_BIN/curl" - chmod +x "$MOCK_BIN/curl" - export PATH="$MOCK_BIN:$PATH" + mock_bin_setup curl # Unset credentials to ensure clean test environment unset PUSHOVER_API_TOKEN unset PUSHOVER_USER_KEY } cleanup() { - rm -rf "$MOCK_BIN" + mock_bin_cleanup } Before 'setup' After 'cleanup' @@ -30,62 +26,177 @@ The output should eq '' End End +Describe 'credential handling via HOME/dotfiles/.env' +setup() { + mock_bin_setup curl + TEMP_HOME=$(mktemp -d) + mkdir -p "$TEMP_HOME/dotfiles" + + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$TEMP_HOME" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'sources .env when credentials are missing' +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_API_TOKEN=from_env_token +PUSHOVER_USER_KEY=from_env_user +ENV +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'token=from_env_token' +The output should include 'user=from_env_user' +End + +It 'does not call curl when .env is incomplete' +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_API_TOKEN=from_env_token +ENV +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should eq '' +End +End + Describe 'SessionEnd hook' setup() { - # Create a mock curl that does nothing - MOCK_BIN=$(mktemp -d) - printf '#!/bin/sh\nexit 0\n' >"$MOCK_BIN/curl" - chmod +x "$MOCK_BIN/curl" - export PATH="$MOCK_BIN:$PATH" + mock_bin_setup curl # Set test credentials export PUSHOVER_API_TOKEN="test_token" export PUSHOVER_USER_KEY="test_user" } cleanup() { - rm -rf "$MOCK_BIN" + mock_bin_cleanup } Before 'setup' After 'cleanup' It 'skips notification for "other" reason' -When run bash -c 'echo "{\"reason\": \"other\"}" | bash '"$SCRIPT" +When run bash -c 'echo "{\"reason\": \"other\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success The output should eq '' End It 'processes notification for "user_exit" reason' -When run bash -c 'echo "{\"reason\": \"user_exit\"}" | bash '"$SCRIPT" +When run bash -c 'echo "{\"reason\": \"user_exit\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success +The output should include 'priority=0' +The output should include 'https://api.pushover.net/1/messages.json' End End Describe 'Notification hook' setup() { - # Create a mock curl that does nothing - MOCK_BIN=$(mktemp -d) - printf '#!/bin/sh\nexit 0\n' >"$MOCK_BIN/curl" - chmod +x "$MOCK_BIN/curl" - export PATH="$MOCK_BIN:$PATH" + mock_bin_setup curl # Set test credentials export PUSHOVER_API_TOKEN="test_token" export PUSHOVER_USER_KEY="test_user" + + TRANSCRIPT=$(mktemp) } cleanup() { - rm -rf "$MOCK_BIN" + rm -f "$TRANSCRIPT" + mock_bin_cleanup } Before 'setup' After 'cleanup' It 'skips login notification' -When run bash -c 'echo "{\"message\": \"Claude Code login successful\"}" | bash '"$SCRIPT" +When run bash -c 'echo "{\"message\": \"Claude Code login successful\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success +The output should eq '' End It 'processes waiting notification' -When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | bash '"$SCRIPT" +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\", \"cwd\": \"/tmp\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'priority=1' +The output should include 'Waiting' +End + +It 'processes permission notification at high priority' +When run bash -c 'echo "{\"message\": \"Claude needs your permission to use Bash\", \"cwd\": \"/tmp\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'priority=1' +The output should include 'Permission required' +End + +It 'includes transcript stats when transcript_path is present' +cat >"$TRANSCRIPT" <<'JSON' +{"type":"user","message":{"content":[{"type":"text","text":"Fix tests"}]}} +{"cwd":"/tmp/project"} +{"type":"tool_use","tool_use":{"name":"Bash","input":{"command":"echo ok"}}} +{"type":"tool_use","tool_use":{"name":"Write","input":{"file_path":"spec/foo_spec.sh"}}} +JSON +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\", \"transcript_path\": \"'"$TRANSCRIPT"'\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'tools,' +The output should include 'files' +End + +It 'prefers environment variables over HOME/dotfiles/.env' +cat >"$TRANSCRIPT" <<'JSON' +{"type":"user","message":{"content":[{"type":"text","text":"Do not source"}]}} +JSON +TEMP_HOME=$(mktemp -d) +mkdir -p "$TEMP_HOME/dotfiles" +cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' +PUSHOVER_API_TOKEN=bad_token +PUSHOVER_USER_KEY=bad_user +ENV +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\", \"transcript_path\": \"'"$TRANSCRIPT"'\"}" | env HOME="'"$TEMP_HOME"'" PUSHOVER_API_TOKEN="test_token" PUSHOVER_USER_KEY="test_user" bash '"$SCRIPT"'; rm -rf "'"$TEMP_HOME"'"; cat "$MOCK_LOG"' +The status should be success +The output should include 'token=test_token' +The output should include 'user=test_user' +End +End + +Describe 'Stop hook' +setup() { + mock_bin_setup curl + + export PUSHOVER_API_TOKEN="test_token" + export PUSHOVER_USER_KEY="test_user" + + TRANSCRIPT=$(mktemp) +} +cleanup() { + rm -f "$TRANSCRIPT" + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'notifies plan ready for approval (plan mode + ExitPlanMode + no files)' +cat >"$TRANSCRIPT" <<'JSON' +{"type":"user","message":{"content":[{"type":"text","text":"Do the thing"}]}} +{"cwd":"/tmp/project"} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"ExitPlanMode"}]}} +JSON +When run bash -c 'echo "{\"hook_event_name\": \"Stop\", \"transcript_path\": \"'"$TRANSCRIPT"'\", \"permission_mode\": \"plan\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' +The status should be success +The output should include 'Plan ready for approval' +The output should include 'priority=1' +End + +It 'notifies work completed when files were modified' +cat >"$TRANSCRIPT" <<'JSON' +{"type":"user","message":{"content":[{"type":"text","text":"Update config"}]}} +{"cwd":"/tmp/project"} +{"type":"tool_use","tool_use":{"name":"Write","input":{"file_path":"config/foo"}}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}} +JSON +When run bash -c 'echo "{\"hook_event_name\": \"Stop\", \"transcript_path\": \"'"$TRANSCRIPT"'\", \"permission_mode\": \"plan\"}" | bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success +The output should include 'Work completed' +The output should include 'priority=0' End End End diff --git a/spec/security_spec.sh b/spec/security_spec.sh index 9a3e547b9..99634c8fd 100644 --- a/spec/security_spec.sh +++ b/spec/security_spec.sh @@ -77,6 +77,41 @@ When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" The status should eq 2 The stderr should include 'BLOCKED' End + +It 'blocks sudo with arguments (legacy sudo:* pattern)' +Data '{"tool": {"name": "Bash", "input": {"command": "sudo rm -rf /"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks hidden dangerous command after semicolon' +Data '{"tool": {"name": "Bash", "input": {"command": "echo ok; rm -rf /*"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks hidden dangerous command after &&' +Data '{"tool": {"name": "Bash", "input": {"command": "echo ok && rm -rf /*"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks dd if=... (legacy dd if=:* pattern)' +Data '{"tool": {"name": "Bash", "input": {"command": "dd if=/dev/zero of=/tmp/zero bs=1 count=1"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End + +It 'blocks chmod -R 777' +Data '{"tool": {"name": "Bash", "input": {"command": "chmod -R 777 /tmp"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End End Describe 'edge cases' diff --git a/spec/shell_files_spec.sh b/spec/shell_files_spec.sh new file mode 100644 index 000000000..ac4d33835 --- /dev/null +++ b/spec/shell_files_spec.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'all tracked shell files' + +detect_shell_type() { + local file="$1" + local first_line="" + + case "$file" in + *.fish) printf '%s\n' fish && return 0 ;; + esac + + IFS= read -r first_line <"$file" || first_line="" + case "$first_line" in + '#!'*fish*) printf '%s\n' fish ;; + '#!'*zsh*) printf '%s\n' zsh ;; + '#!'*ksh*) printf '%s\n' ksh ;; + '#!'*bash*) printf '%s\n' bash ;; + '#!'*sh*) printf '%s\n' sh ;; + *) printf '%s\n' bash ;; + esac +} + +syntax_check() { + local shell_type="$1" + local file="$2" + + case "$shell_type" in + fish) fish -n "$file" ;; + zsh) zsh -n "$file" ;; + ksh) ksh -n "$file" ;; + sh) sh -n "$file" ;; + bash | *) bash -n "$file" ;; + esac +} + +Parameters:dynamic +for file in $(git ls-files '*.sh'); do + %data "$(detect_shell_type "$file")" "$file" +done + +for file in $(git ls-files); do + case "$file" in + *.sh) continue ;; + *.fish) continue ;; + esac + + [ -f "$file" ] || continue + + IFS= read -r first_line <"$file" || first_line="" + case "$first_line" in + '#!'*bash* | '#!'*zsh* | '#!'*ksh* | '#!'*fish* | '#!'*sh*) + %data "$(detect_shell_type "$file")" "$file" + ;; + esac +done + +for file in $(git ls-files '*.fish'); do + %data fish "$file" +done +End + +It "has valid shell syntax: $2 ($1)" +if ! command -v "$1" >/dev/null 2>&1; then + Skip "$1 is not installed" +fi +When call syntax_check "$1" "$2" +The status should be success +End + +End diff --git a/spec/support/custom_matcher.sh b/spec/support/custom_matcher.sh index f1f641af1..7bafc7d62 100644 --- a/spec/support/custom_matcher.sh +++ b/spec/support/custom_matcher.sh @@ -1 +1,35 @@ #!/usr/bin/env bash + +set -euo pipefail + +mock_bin_setup() { + MOCK_BIN="$(mktemp -d)" + MOCK_LOG="$MOCK_BIN/mock.log" + : >"$MOCK_LOG" + + MOCK_ORIGINAL_PATH="${PATH:-}" + export MOCK_BIN MOCK_LOG MOCK_ORIGINAL_PATH + export PATH="$MOCK_BIN:$MOCK_ORIGINAL_PATH" + + local cmd + for cmd in "$@"; do + cat >"$MOCK_BIN/$cmd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: "${MOCK_LOG:?MOCK_LOG must be set}" +printf '%s\n' "$0 $*" >>"$MOCK_LOG" +exit 0 +EOF + chmod +x "$MOCK_BIN/$cmd" + done +} + +mock_bin_cleanup() { + if [[ -n ${MOCK_ORIGINAL_PATH:-} ]]; then + export PATH="$MOCK_ORIGINAL_PATH" + fi + if [[ -n ${MOCK_BIN:-} ]]; then + rm -rf "$MOCK_BIN" + fi + unset MOCK_BIN MOCK_LOG MOCK_ORIGINAL_PATH +}