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: 5 additions & 4 deletions config/claude/security.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better robustness and portability, it's recommended to use printf instead of echo when piping variable content to other commands. echo can have surprising behavior if the variable's content starts with a hyphen (-) or contains backslash escape sequences, and its implementation varies between shells. printf '%s' "$command" is a safer alternative that will print the string exactly as is.

Suggested change
IFS=$'\n' read -r -d '' -a segments < <(echo "$command" | sed -E 's/[;&|]+/\n/g' && printf '\0') || true
IFS=$'\n' read -r -d '' -a segments < <(printf '%s' "$command" | sed -E 's/[;&|]+ /\n/g' && printf '\0') || true


for segment in "${segments[@]}"; do
# Trim leading/trailing whitespace
Expand Down
168 changes: 155 additions & 13 deletions spec/notify_spec.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# shellcheck disable=SC2329
# shellcheck disable=SC2329,SC2016

Describe 'notify.sh'
SCRIPT="$PWD/config/claude/notify.sh"
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Loading
Loading