diff --git a/.env.example b/.env.example index 63be8df5d..c31a9a780 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ # Copy to home-manager/.env and provide real values. The .env file stays local. MY_SECRET=replace-me # GITHUB_TOKEN=ghp_your_token_here +CLIPROXY_MANAGEMENT_PASSWORD=your-management-key-here diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml new file mode 100644 index 000000000..258232121 --- /dev/null +++ b/.github/workflows/shell.yml @@ -0,0 +1,48 @@ +name: Shell +on: + push: + branches: + - main + pull_request: + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true +jobs: + shellspec: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run ShellSpec Tests (Dev Shell) + run: make shell-test-dev + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run ShellCheck (Dev Shell) + run: make shell-check-dev + shell-check: + if: always() + needs: + - shellspec + - shellcheck + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Alls Green + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.shellspec b/.shellspec new file mode 100644 index 000000000..909b8d05c --- /dev/null +++ b/.shellspec @@ -0,0 +1,3 @@ +--require spec_helper +--format progress +--shell bash diff --git a/Makefile b/Makefile index 3d2fa75a4..c12501309 100644 --- a/Makefile +++ b/Makefile @@ -135,10 +135,10 @@ setup: nix-setup ## Basic Nix setup (alias for nix-setup). setup-dev: nix-setup git-submodule-sync shell-install ## Set up local development environment (Nix + submodules + shell). .PHONY: switch -switch: nix-switch ## Apply Nix configuration and restart launchd agents. +switch: nix-switch launchctl ## Apply Nix configuration and restart launchd agents. .PHONY: test -test: neovim-test +test: neovim-test shell-test ## Run all tests (neovim + shell). .PHONY: update update: nix-update shell-update neovim-update ## Update Nix flake and configurations. @@ -202,8 +202,8 @@ devenv-cli: ## Build the packaged devenv CLI binary. .PHONY: nix-install nix-install: ## Install Nix if not already installed. @if [ "$(NIX_ENV)" = "not_found" ]; then \ - echo "๐Ÿš€ Installing Nix environment for $(NIX_CONFIG_TYPE) on $(OS) $(ARCH) for USER=$(NIX_USERNAME)"; \ - curl -L https://nixos.org/nix/install | sh; \ + echo "๐Ÿš€ Installing Determinate Nix environment for $(NIX_CONFIG_TYPE) on $(OS) $(ARCH) for USER=$(NIX_USERNAME)"; \ + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install; \ fi @echo "โœ… Nix environment installed!" @@ -624,3 +624,25 @@ git-submodule-sync: ## Sync and update git submodules. @git submodule sync @git submodule update --init --recursive @echo "โœ… Submodules synced and updated" + +##@ Shell + +.PHONY: shell-test +shell-test: ## Run shell script tests using ShellSpec. + @echo "๐Ÿงช Running shell tests..." + @bash -c "shellspec" + +.PHONY: shell-test-dev +shell-test-dev: ## Run shell tests inside the Nix dev shell (mirrors CI). + @echo "๐Ÿงช Running shell tests inside the Nix dev shell..." + @DEVENV_ROOT=$(CURDIR) $(NIX_ALLOW_UNFREE) $(NIX_EXEC) develop $(NIX_FLAGS) .# --command $(MAKE) shell-test + +.PHONY: shell-check +shell-check: ## Run ShellCheck on shell scripts. + @echo "๐Ÿ” Running ShellCheck..." + @find . -name '*.sh' -not -path './node_modules/*' -not -path './.git/*' -not -path './result/*' | xargs shellcheck + +.PHONY: shell-check-dev +shell-check-dev: ## Run ShellCheck inside the Nix dev shell (mirrors CI). + @echo "๐Ÿ” Running ShellCheck inside the Nix dev shell..." + @DEVENV_ROOT=$(CURDIR) $(NIX_ALLOW_UNFREE) $(NIX_EXEC) develop $(NIX_FLAGS) .# --command $(MAKE) shell-check diff --git a/README-nix.md b/README-nix.md index e91979e0c..7ce44362c 100644 --- a/README-nix.md +++ b/README-nix.md @@ -3,8 +3,8 @@ This repo can build and expose all CLI tools declared in `package.json` using the exact versions from the lockfile, via [dream2nix]. ## Prerequisites -- Nix installed -- Flakes enabled (see Nix docs) +- Nix installed (recommended: [Determinate Nix](https://determinate.systems/nix-installer/)) +- Flakes enabled (automatically enabled with Determinate Nix) ## Typical usage diff --git a/config/claude/default.nix b/config/claude/default.nix index 0a439a7b5..ce3bc5d34 100644 --- a/config/claude/default.nix +++ b/config/claude/default.nix @@ -13,4 +13,9 @@ source = ./notify.sh; executable = true; }; + + home.file.".claude/security.sh" = { + source = ./security.sh; + executable = true; + }; } diff --git a/config/claude/notify.sh b/config/claude/notify.sh index 1bf8769b6..5f9be4d18 100644 --- a/config/claude/notify.sh +++ b/config/claude/notify.sh @@ -8,6 +8,7 @@ if [ -z "$PUSHOVER_API_TOKEN" ] || [ -z "$PUSHOVER_USER_KEY" ]; then if [ -f "$HOME/dotfiles/.env" ]; then set -a + # shellcheck source=/dev/null source "$HOME/dotfiles/.env" 2>/dev/null set +a fi diff --git a/config/claude/pushover.sh b/config/claude/pushover.sh index 9a2732882..65f4b33e8 100755 --- a/config/claude/pushover.sh +++ b/config/claude/pushover.sh @@ -9,8 +9,8 @@ # inherit shell environment variables if [ -z "$PUSHOVER_API_TOKEN" ] || [ -z "$PUSHOVER_USER_KEY" ]; then if [ -f "$HOME/dotfiles/.env" ]; then - # shellcheck source=/dev/null set -a + # shellcheck source=/dev/null source "$HOME/dotfiles/.env" 2>/dev/null set +a fi @@ -112,8 +112,12 @@ if echo "$input" | jq -e '.message' >/dev/null 2>&1; then fi # Handle SessionEnd hook (priority 0 = normal) +# Skip "other" reason - it's a generic/unknown reason that's noisy if echo "$input" | jq -e '.reason' >/dev/null 2>&1; then REASON=$(echo "$input" | jq -r '.reason') + if [ "$REASON" = "other" ]; then + exit 0 + fi send_notification "๐Ÿ‘‹ Session ended: ${REASON}" 0 exit 0 fi diff --git a/config/claude/security.sh b/config/claude/security.sh new file mode 100644 index 000000000..40bc3dddc --- /dev/null +++ b/config/claude/security.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Claude Code Security Hook +# Blocks dangerous Bash commands by checking against deny patterns +# Based on: https://wasabeef.jp/blog/claude-code-secure-bash +# +# This script runs as a PreToolUse hook and returns: +# - Exit code 0: Command is allowed +# - Exit code 2: Command is blocked + +set -euo pipefail + +# Read JSON input from stdin +input=$(cat) + +# Extract tool name - only process Bash commands +tool_name=$(echo "$input" | jq -r '.tool.name // empty' 2>/dev/null) +[[ $tool_name != "Bash" ]] && exit 0 + +# Extract the command to be executed +command=$(echo "$input" | jq -r '.tool.input.command // empty' 2>/dev/null) +[[ -z $command ]] && exit 0 + +# Path to settings file with deny patterns +settings="$HOME/.claude/settings.json" +[[ ! -f $settings ]] && exit 0 + +# Read deny patterns from settings +mapfile -t deny_patterns < <(jq -r '.permissions.deny[]?' "$settings" 2>/dev/null) + +# Function to check if a command matches a pattern +# Supports glob-style wildcards +matches_pattern() { + local cmd="$1" + local pattern="$2" + + # 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%:*}" + + # Use bash glob matching (extended globbing) + shopt -s extglob + # shellcheck disable=SC2053 + if [[ $cmd == $check_pattern ]]; then + return 0 + fi + shopt -u extglob + fi + return 1 +} + +# 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 + +for segment in "${segments[@]}"; do + # Trim leading/trailing whitespace + segment=$(echo "$segment" | xargs 2>/dev/null) || continue + [[ -z $segment ]] && continue + + for pattern in "${deny_patterns[@]}"; do + if matches_pattern "$segment" "$pattern"; then + echo "BLOCKED by security.sh: Command '$segment' matches deny pattern '$pattern'" >&2 + exit 2 + fi + done +done + +# Command passed all checks +exit 0 diff --git a/config/claude/settings.json b/config/claude/settings.json index 96bc38568..b2cc40220 100644 --- a/config/claude/settings.json +++ b/config/claude/settings.json @@ -1,4 +1,5 @@ { + "cleanupPeriodDays": 99999, "permissions": { "allow": [ "Bash(bun:*)", @@ -37,7 +38,6 @@ "Bash(git status:*)", "Bash(git tag:*)", "Bash(git worktree:*)", - "Bash(go:*)", "Bash(grep:*)", "Bash(kubectl:*)", "Bash(lsof:*)", @@ -52,8 +52,6 @@ "Bash(pip:*)", "Bash(pip3:*)", "Bash(pnpm:*)", - "Bash(python:*)", - "Bash(python3:*)", "Bash(scutil:*)", "Bash(timeout:*)", "Bash(uv:*)", @@ -69,7 +67,30 @@ "Bash(chmod -R 777:*)", "Bash(mkfs:*)", "Bash(dd if=:*)" - ] + ], + "read": { + "deny": [ + "**/.env", + "**/.env.local", + "**/*.pem", + "**/*.key", + "**/secrets/**", + "**/credentials/**", + "**/.aws/**", + "**/.ssh/**", + "**/docker-compose*.yml", + "**/config/database.yml" + ] + }, + "write": { + "deny": [ + "**/secrets/**", + "**/credentials/**", + "**/.ssh/**", + "**/config/database.yml", + "**/docker-compose*.yml" + ] + } }, "hooks": { "UserPromptSubmit": [ @@ -103,6 +124,11 @@ { "matcher": "Bash", "hooks": [ + { + "type": "command", + "command": "$HOME/.claude/security.sh", + "timeout": 5 + }, { "type": "command", "command": "$HOME/.claude/notify.sh", @@ -201,5 +227,6 @@ "type": "command", "command": "node -e \"const fs=require('fs'),path=require('path'),rl=require('readline');let i='';process.stdin.on('data',c=>i+=c);process.stdin.on('end',async()=>{try{const d=JSON.parse(i);const m=d.model?.display_name||'claude-sonnet-4';const dir=d.workspace?.current_dir?.replace(process.env.HOME,'~')||path.basename(process.cwd());const sid=d.session_id;let t=0;if(sid){const pdir=path.join(process.env.HOME,'.claude','projects');if(fs.existsSync(pdir)){for(const pd of fs.readdirSync(pdir).map(d=>path.join(pdir,d)).filter(d=>fs.statSync(d).isDirectory())){const tf=path.join(pd,sid+'.jsonl');if(fs.existsSync(tf)){const rs=fs.createReadStream(tf);const r=rl.createInterface({input:rs});let lu=null;r.on('line',l=>{try{const e=JSON.parse(l);if(e.type==='assistant'&&e.message?.usage)lu=e.message.usage}catch{}});await new Promise(res=>{r.on('close',()=>{if(lu)t=(lu.input_tokens||0)+(lu.output_tokens||0)+(lu.cache_creation_input_tokens||0)+(lu.cache_read_input_tokens||0);res()})});break}}}}const getContextLimit=m=>{if(m.includes('sonnet-4'))return 500000;if(m.includes('3-5-sonnet')||m.includes('sonnet-3.5'))return 200000;if(m.includes('haiku'))return 200000;if(m.includes('opus'))return 200000;return 200000};const cl=getContextLimit(m);const p=Math.min(100,Math.round(t/cl*100));const td=t>=1000000?(t/1000000).toFixed(1)+'M':t>=1000?(t/1000).toFixed(1)+'K':t.toString();const ld=cl>=1000000?(cl/1000000).toFixed(0)+'M':cl>=1000?(cl/1000).toFixed(0)+'K':cl.toString();const c=p>=90?'\\x1b[31m':p>=70?'\\x1b[33m':'\\x1b[32m';console.log('\\x1b[34m'+process.env.USER+'@'+require('os').hostname().split('.')[0]+'\\x1b[0m:\\x1b[34m'+dir+'\\x1b[0m ['+m+'] ๐Ÿช™ '+td+'/'+ld+' '+c+p+'%\\x1b[0m '+new Date().toLocaleTimeString('en-US',{hour12:false}))}catch{console.log('\\x1b[34m'+process.env.USER+'@'+require('os').hostname().split('.')[0]+'\\x1b[0m:\\x1b[34m'+(process.cwd().replace(process.env.HOME,'~'))+'\\x1b[0m [claude-sonnet-4] ๐Ÿช™ 0 \\x1b[31mโŒ\\x1b[0m '+new Date().toLocaleTimeString('en-US',{hour12:false}))}});\"" }, - "model": "sonnet" + "model": "sonnet", + "defaultMode": "bypassPermissions" } diff --git a/config/cliproxyapi/config.yaml b/config/cliproxyapi/config.yaml index 4fde4d2a8..43c09dcfd 100644 --- a/config/cliproxyapi/config.yaml +++ b/config/cliproxyapi/config.yaml @@ -4,11 +4,11 @@ port: 8317 remote-management: # Whether to allow remote (non-localhost) management access. # When false, only localhost can access management endpoints (a key is still required). - allow-remote: true + allow-remote: false # Management key. If a plaintext value is provided here, it will be hashed on startup. # All management requests (even from localhost) require this key. # Leave empty to disable the Management API entirely (404 for all /v0/management routes). - secret-key: "" + secret-key: "__CLIPROXY_MANAGEMENT_PASSWORD__" # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false # Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/` diff --git a/devenv.nix b/devenv.nix index 2dc95190e..a6a786769 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,6 +5,8 @@ pkgs.nodejs pkgs.bun pkgs.neovim + pkgs.shellcheck + pkgs.shellspec ]; containers = pkgs.lib.mkIf (!pkgs.stdenv.hostPlatform.isLinux) (pkgs.lib.mkForce { }); diff --git a/flake.lock b/flake.lock index 88143df1b..850e5a943 100644 --- a/flake.lock +++ b/flake.lock @@ -405,11 +405,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1765270179, - "narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=", + "lastModified": 1765425892, + "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9", + "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", "type": "github" }, "original": { @@ -442,11 +442,11 @@ ] }, "locked": { - "lastModified": 1765505503, - "narHash": "sha256-SzQIGny2oxbVTCPk2WpzuVnx47G3Y95LrGAts4UUUes=", + "lastModified": 1765567938, + "narHash": "sha256-cT4D+Q1n1zKEfur3z6x01fUhB8IlvfG9V8WBaJqzhZ0=", "owner": "nix-community", "repo": "NUR", - "rev": "8d9628632b91c32828c9707f2c74a84d2651234a", + "rev": "5414593b5c7fb86679b0f10184ecde9e5849275a", "type": "github" }, "original": { diff --git a/home-manager/packages/default.nix b/home-manager/packages/default.nix index ffe8a1fca..6da10a6f4 100644 --- a/home-manager/packages/default.nix +++ b/home-manager/packages/default.nix @@ -61,6 +61,8 @@ with pkgs; ripgrep rustup sccache + shellcheck + shellspec speedtest-cli sqlite stern diff --git a/home-manager/programs/fish/default.nix b/home-manager/programs/fish/default.nix index a75a0cf2f..80e13430c 100644 --- a/home-manager/programs/fish/default.nix +++ b/home-manager/programs/fish/default.nix @@ -60,6 +60,7 @@ grco = "_grco_function"; grcr = "_grcr_function"; kyber = "_kyber_function"; + kybers = "_kybers_function"; fch = "_fzf_cmd_history --allow-execute"; fdp = "_fzf_directory_picker --allow-cd --prompt-name Projects ~/"; ffp = "_fzf_file_picker --allow-open-in-editor --prompt-name Files"; @@ -133,6 +134,7 @@ "_grcr_function" "_hm_load_env_file" "_kyber_function" + "_kybers_function" "fish_user_key_bindings" ] ); diff --git a/home-manager/programs/fish/functions/_kyber_function.fish b/home-manager/programs/fish/functions/_kyber_function.fish index a90a09a56..b68f2eb61 100644 --- a/home-manager/programs/fish/functions/_kyber_function.fish +++ b/home-manager/programs/fish/functions/_kyber_function.fish @@ -1,9 +1,3 @@ -function _kyber_function --description "SSH to Kyber server" - set -l password (security find-generic-password -s "ssh ubuntu@91.242.214.231" -w 2>/dev/null) - if test -n "$password" - sshpass -p $password ssh ubuntu@$KYBER_IP_ADDR - else - echo "Password not found in Keychain. Run: security add-generic-password -s 'ssh ubuntu@91.242.214.231' -a ubuntu -w" - ssh ubuntu@$KYBER_IP_ADDR - end +function _kyber_function --description "SSH to Kyber server via Tailscale" + tailscale ssh ubuntu@kyber end diff --git a/home-manager/programs/fish/functions/_kybers_function.fish b/home-manager/programs/fish/functions/_kybers_function.fish new file mode 100644 index 000000000..acd8dae89 --- /dev/null +++ b/home-manager/programs/fish/functions/_kybers_function.fish @@ -0,0 +1,3 @@ +function _kybers_function --description "SSH to Kyber server via Tailscale with zellij" + tailscale ssh -t ubuntu@kyber zellij attach -c +end diff --git a/home-manager/services/cliproxyapi/start.sh b/home-manager/services/cliproxyapi/start.sh index d3cf15bac..70aecea80 100755 --- a/home-manager/services/cliproxyapi/start.sh +++ b/home-manager/services/cliproxyapi/start.sh @@ -15,9 +15,14 @@ if [ -f "$ENV_FILE" ]; then set +a fi +# Export management password for Management API (CLIProxyAPI requires MANAGEMENT_PASSWORD env var) +export MANAGEMENT_PASSWORD="${CLIPROXY_MANAGEMENT_PASSWORD:-}" + # Generate config from template with secrets injected if [ -f "$TEMPLATE" ]; then - sed "s|__OPENROUTER_API_KEY__|${OPENROUTER_API_KEY:-}|g" "$TEMPLATE" >"$CONFIG" + sed -e "s|__OPENROUTER_API_KEY__|${OPENROUTER_API_KEY:-}|g" \ + -e "s|__CLIPROXY_MANAGEMENT_PASSWORD__|${CLIPROXY_MANAGEMENT_PASSWORD:-}|g" \ + "$TEMPLATE" >"$CONFIG" fi # Change to config dir so logs are created there diff --git a/home-manager/services/code-syncer/sync.sh b/home-manager/services/code-syncer/sync.sh index 59651afe7..b65ec4d40 100755 --- a/home-manager/services/code-syncer/sync.sh +++ b/home-manager/services/code-syncer/sync.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034,SC2155,SC2181,SC2162 # --- CONFIGURATION --- diff --git a/install.sh b/install.sh index b01a48687..7318f5f16 100755 --- a/install.sh +++ b/install.sh @@ -42,18 +42,20 @@ fi if ! command -v nix >/dev/null 2>&1; then echo "Installing Nix..." if [ "$OS" = "macos" ]; then - curl -L https://nixos.org/nix/install | bash + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install # For macOS, source the Nix profile immediately to update PATH in CI. + # shellcheck source=/dev/null . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh NIX_EFFECTIVE_BIN_PATH="/nix/var/nix/profiles/default/bin" else # Linux if [ "$IN_DOCKER" = "true" ]; then - echo "Performing single-user Nix installation (Docker environment)..." - curl -L https://nixos.org/nix/install | bash -s -- --no-daemon + echo "Performing Determinate Nix installation (Docker environment)..." + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --init none --no-confirm # Source the Nix profile script to add Nix to PATH for the current shell if [ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + # shellcheck source=/dev/null . "$HOME/.nix-profile/etc/profile.d/nix.sh" - echo "Sourced Nix profile for single-user (Docker) setup." + echo "Sourced Nix profile for Determinate Nix (Docker) setup." else echo "Warning: Nix profile script ($HOME/.nix-profile/etc/profile.d/nix.sh) not found after installation." # Fallback PATH export for the current shell @@ -61,10 +63,10 @@ if ! command -v nix >/dev/null 2>&1; then fi NIX_EFFECTIVE_BIN_PATH="$HOME/.nix-profile/bin" else # Linux multi-user - echo "Performing multi-user Nix installation..." - curl -L https://nixos.org/nix/install | bash -s -- --daemon + echo "Performing Determinate Nix multi-user installation..." + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux # For Linux multi-user installations, add the default Nix path for the current shell. - export PATH=/nix/var/nix/profiles/default/bin:$PATH + export PATH="/nix/var/nix/profiles/default/bin:$PATH" NIX_EFFECTIVE_BIN_PATH="/nix/var/nix/profiles/default/bin" fi fi diff --git a/named-hosts/kyber/README.md b/named-hosts/kyber/README.md index b947c51d4..7e31cd332 100644 --- a/named-hosts/kyber/README.md +++ b/named-hosts/kyber/README.md @@ -13,7 +13,7 @@ sudo systemctl enable --now tailscaled sudo tailscale up # 2. Install Nix -curl -L https://nixos.org/nix/install | sh -s -- --daemon +curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux # 3. Clone dotfiles git clone https://github.com/shunkakinoki/dotfiles ~/dotfiles diff --git a/overlays/default.nix b/overlays/default.nix index 39265f679..0bab21d52 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -18,4 +18,17 @@ # Provide non-deprecated alias so upstream modules using pkgs.system don't emit warnings. system = prev.stdenv.hostPlatform.system; }) + (final: prev: { + # Fix shellspec wrapper script that breaks when called via symlinks + shellspec = prev.shellspec.overrideAttrs (oldAttrs: { + postInstall = (oldAttrs.postInstall or "") + '' + # Replace the wrapper with one that uses an absolute path + cat > $out/bin/shellspec << EOF + #!${prev.bash}/bin/sh + exec "$out/lib/shellspec/shellspec" "\$@" + EOF + chmod +x $out/bin/shellspec + ''; + }); + }) ] diff --git a/spec/notify_spec.sh b/spec/notify_spec.sh new file mode 100644 index 000000000..7775a0c7a --- /dev/null +++ b/spec/notify_spec.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'notify.sh' +SCRIPT="$PWD/config/claude/notify.sh" + +Describe 'when Pushover is configured' +setup() { + export PUSHOVER_API_TOKEN="test_token" + export PUSHOVER_USER_KEY="test_user" +} +Before 'setup' + +It 'exits early and skips local notification' +When run bash -c 'echo "{\"message\": \"Test message\"}" | bash '"$SCRIPT" +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" + + # Unset Pushover credentials to ensure clean test environment + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$MOCK_BIN" +} +Before 'setup' +After 'cleanup' + +It 'exits 0 for login notification' +# Use fake HOME so script cannot source real .env file +When run bash -c 'echo "{\"message\": \"Claude Code login successful\"}" | env HOME=/nonexistent bash '"$SCRIPT" +The status should be success +End + +It 'exits 0 for waiting notification' +# Use fake HOME so script cannot source real .env file +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | env HOME=/nonexistent bash '"$SCRIPT" +The status should be success +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" + + # Unset Pushover credentials to ensure clean test environment + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$MOCK_BIN" +} +Before 'setup' +After 'cleanup' + +It 'exits 0 for session end' +# Use fake HOME so script cannot source real .env file +When run bash -c 'echo "{\"reason\": \"user_exit\"}" | env HOME=/nonexistent bash '"$SCRIPT" +The status should be success +End +End +End diff --git a/spec/pushover_spec.sh b/spec/pushover_spec.sh new file mode 100644 index 000000000..9786a3d95 --- /dev/null +++ b/spec/pushover_spec.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +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" + + # Unset credentials to ensure clean test environment + unset PUSHOVER_API_TOKEN + unset PUSHOVER_USER_KEY +} +cleanup() { + rm -rf "$MOCK_BIN" +} +Before 'setup' +After 'cleanup' + +It 'exits 0 when no credentials are set' +# Use fake HOME so script cannot find credentials +When run bash -c 'echo "{}" | env HOME=/nonexistent bash '"$SCRIPT" +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" + + # Set test credentials + export PUSHOVER_API_TOKEN="test_token" + export PUSHOVER_USER_KEY="test_user" +} +cleanup() { + rm -rf "$MOCK_BIN" +} +Before 'setup' +After 'cleanup' + +It 'skips notification for "other" reason' +When run bash -c 'echo "{\"reason\": \"other\"}" | bash '"$SCRIPT" +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" +The status should be success +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" + + # Set test credentials + export PUSHOVER_API_TOKEN="test_token" + export PUSHOVER_USER_KEY="test_user" +} +cleanup() { + rm -rf "$MOCK_BIN" +} +Before 'setup' +After 'cleanup' + +It 'skips login notification' +When run bash -c 'echo "{\"message\": \"Claude Code login successful\"}" | bash '"$SCRIPT" +The status should be success +End + +It 'processes waiting notification' +When run bash -c 'echo "{\"message\": \"Claude is waiting for your input\"}" | bash '"$SCRIPT" +The status should be success +End +End +End diff --git a/spec/security_spec.sh b/spec/security_spec.sh new file mode 100644 index 000000000..9a3e547b9 --- /dev/null +++ b/spec/security_spec.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'security.sh' +SCRIPT="$PWD/config/claude/security.sh" + +setup() { + TEMP_HOME=$(mktemp -d) + mkdir -p "$TEMP_HOME/.claude" + cat >"$TEMP_HOME/.claude/settings.json" <<'SETTINGS' +{ + "permissions": { + "deny": [ + "Bash(sudo:*)", + "Bash(rm -rf /*:*)", + "Bash(rm -rf ~/*:*)", + "Bash(chmod -R 777:*)", + "Bash(mkfs:*)", + "Bash(dd if=:*)" + ] + } +} +SETTINGS +} + +cleanup() { + rm -rf "$TEMP_HOME" +} + +Before 'setup' +After 'cleanup' + +Describe 'tool filtering' +It 'passes non-Bash tools through' +Data '{"tool": {"name": "Read", "input": {}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End + +It 'passes when no tool specified' +Data '{}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End +End + +Describe 'safe commands' +It 'allows ls -la' +Data '{"tool": {"name": "Bash", "input": {"command": "ls -la"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End + +It 'allows git status' +Data '{"tool": {"name": "Bash", "input": {"command": "git status"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End + +It 'allows cat /etc/hosts' +Data '{"tool": {"name": "Bash", "input": {"command": "cat /etc/hosts"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End + +It 'allows echo hello' +Data '{"tool": {"name": "Bash", "input": {"command": "echo hello"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End +End + +Describe 'blocked commands' +It 'blocks rm -rf /*' +Data '{"tool": {"name": "Bash", "input": {"command": "rm -rf /*"}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should eq 2 +The stderr should include 'BLOCKED' +End +End + +Describe 'edge cases' +It 'passes with empty input' +Data '{"tool": {"name": "Bash", "input": {}}}' +When run bash -c "HOME='$TEMP_HOME' bash '$SCRIPT'" +The status should be success +End +End +End diff --git a/spec/spec_helper.sh b/spec/spec_helper.sh new file mode 100644 index 000000000..23bd7bad7 --- /dev/null +++ b/spec/spec_helper.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -eu + +spec_helper_precheck() { + minimum_version "0.28.1" +} + +spec_helper_configure() { + import 'support/custom_matcher' +} diff --git a/spec/support/custom_matcher.sh b/spec/support/custom_matcher.sh new file mode 100644 index 000000000..f1f641af1 --- /dev/null +++ b/spec/support/custom_matcher.sh @@ -0,0 +1 @@ +#!/usr/bin/env bash