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
29 changes: 19 additions & 10 deletions .claude/hooks/block_dangerous_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,47 +18,56 @@
# Normalize: collapse whitespace, lowercase for pattern matching
normalized = " ".join(cmd.split()).lower()

# Strip single- and double-quoted string content so that shell separators
# inside argument values (e.g. --name 'a;b') are not treated as real command
# boundaries by [^|&;<>]* patterns. This prevents the quoted-separator bypass
# identified in Codex P1 review (PR #795).
normalized = re.sub(r"'[^']*'", " ", normalized)
normalized = re.sub(r'"[^"]*"', " ", normalized)
Comment on lines +21 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Quoted-content stripping introduces a dangerous-command bypass.

At Line 25-26, full quoted payloads are removed before matching. bash -c "rm -rf /" is normalized to essentially bash -c, so destructive commands inside -c are no longer detectable.

Suggested fix
-# Strip single- and double-quoted string content so that shell separators
-# inside argument values (e.g. --name 'a;b') are not treated as real command
-# boundaries by [^|&;<>]* patterns. This prevents the quoted-separator bypass
-# identified in Codex P1 review (PR `#795`).
-normalized = re.sub(r"'[^']*'", " ", normalized)
-normalized = re.sub(r'"[^"]*"', " ", normalized)
+# Neutralize separators inside quoted strings without removing quoted command text.
+def _neutralize_quoted_separators(text: str) -> str:
+    text = re.sub(
+        r"'([^']*)'",
+        lambda m: "'" + re.sub(r"[|&;<>]", " ", m.group(1)) + "'",
+        text,
+    )
+    return re.sub(
+        r'"([^"]*)"',
+        lambda m: '"' + re.sub(r"[|&;<>]", " ", m.group(1)) + '"',
+        text,
+    )
+
+normalized = _neutralize_quoted_separators(normalized)

As per coding guidelines, **/*.{js,ts,jsx,tsx,py,java,go,rb,php,json,lock,txt,yaml,yml} must fail on critical security vulnerabilities during Security Code Analysis.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/hooks/block_dangerous_commands.py around lines 21 - 26, The current
quoted-content stripping (the two re.sub lines that assign to normalized)
removes entire quoted payloads and can hide dangerous commands passed via flags
like -c; instead, only neutralize shell metacharacters inside quoted strings so
the surrounding command and flags remain visible to pattern matching. Update the
logic in block_dangerous_commands.py that builds normalized to find single- and
double-quoted spans (the existing "'[^']*'" and '"[^"]*"') and replace only
dangerous separators (e.g., | & ; < > ` $ ( ) and backticks/newlines as needed)
within those quotes with spaces or another safe placeholder while preserving the
rest of the quoted text, so functions/patterns that detect commands like bash -c
still see the argument content for dangerous tokens.


# ── Dangerous patterns (regex) ──────────────────────────────────
DANGEROUS_PATTERNS = [
# === Git destructive operations ===
# Bound the wildcard to the push invocation's own args: [^|&;<>]* stops at a
# pipe/redirect/separator so a later `-f` in a chained command or a flattened
# heredoc body (newlines collapse to spaces) cannot trigger a false positive.
# NOTE: flag wildcards use [^|&;<>]* (not .*) so the match stays within a
# single command's own arguments. normalize() flattens newlines to spaces,
# so a greedy .* would otherwise reach a flag-like token in a chained command
# (`a && b`), a pipe (`| x`), a redirect (`2>&1`) or a heredoc body, causing
# false positives. [^|&;<>]* stops at the first such boundary.
(r"git\s+push\s+[^|&;<>]*--force", "git push --force (force push)"),
(r"git\s+push\s+[^|&;<>]*-f\b", "git push -f (force push)"),
(r"git\s+push\s+[^|&;<>]*--force-with-lease", "git push --force-with-lease"),
(r"git\s+push\s+\S+\s+\+", "git push origin +branch (force push)"),
(r"git\s+clean\s+.*-f", "git clean -f (delete untracked files)"),
(r"git\s+clean\s+[^|&;<>]*-f", "git clean -f (delete untracked files)"),
(r"git\s+reflog\s+expire", "git reflog expire (destroy recovery data)"),
(r"git\s+reset\s+--hard", "git reset --hard (discard all changes)"),

# === chmod dangerous operations ===
(r"chmod\s+777\b", "chmod 777 (world-writable permissions)"),

# === rm destructive operations ===
(r"rm\s+.*-r.*-f|rm\s+.*-f.*-r|rm\s+-rf", "rm -rf (recursive force delete)"),
(r"rm\s+[^|&;<>]*-r[^|&;<>]*-f|rm\s+[^|&;<>]*-f[^|&;<>]*-r|rm\s+-rf", "rm -rf (recursive force delete)"),

# === Docker destructive operations ===
(r"docker\s+system\s+prune", "docker system prune"),
(r"docker\s+volume\s+prune", "docker volume prune (data loss)"),
(r"docker\s+run\s+.*--privileged", "docker run --privileged (host access)"),
(r"docker\s+run\s+[^|&;<>]*--privileged", "docker run --privileged (host access)"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not treat quoted separators as command boundaries

When an argument before the dangerous flag legitimately contains a quoted or escaped separator, this pattern stops scanning too early and allows the same command the hook is meant to block; for example, I checked docker run --name 'a;b' --privileged ubuntu and the hook exits 0, while the equivalent command without the semicolon exits 2. The same bypass applies to the other newly bounded [^|&;<>]* flag patterns, because the hook operates on the raw command string rather than parsed shell tokens.

Useful? React with 👍 / 👎.

(r"docker\s+push\b", "docker push (registry publish)"),

# === Kubernetes destructive operations ===
(r"kubectl\s+delete\s+(deployment|service|pvc|statefulset|ingress|daemonset|cronjob|job)\b",
"kubectl delete (workload/resource destruction)"),
(r"kubectl\s+delete\s+(namespace|ns)\b", "kubectl delete namespace"),
(r"kubectl\s+delete\s+pod\s+.*--all", "kubectl delete pod --all"),
(r"kubectl\s+scale\s+.*--replicas\s*=\s*0", "kubectl scale --replicas=0 (service stop)"),
(r"kubectl\s+delete\s+pod\s+[^|&;<>]*--all", "kubectl delete pod --all"),
(r"kubectl\s+scale\s+[^|&;<>]*--replicas\s*=\s*0", "kubectl scale --replicas=0 (service stop)"),

# === Terraform destructive operations ===
(r"terraform\s+destroy", "terraform destroy"),
(r"terraform\s+apply\s+.*-auto-approve", "terraform apply -auto-approve"),
(r"terraform\s+apply\s+[^|&;<>]*-auto-approve", "terraform apply -auto-approve"),
(r"terraform\s+state\s+rm", "terraform state rm (orphan resources)"),

# === AWS destructive operations ===
(r"aws\s+ec2\s+terminate-instances", "aws ec2 terminate-instances"),
(r"aws\s+s3\s+rm\s+.*--recursive", "aws s3 rm --recursive (bulk delete)"),
(r"aws\s+s3\s+rm\s+[^|&;<>]*--recursive", "aws s3 rm --recursive (bulk delete)"),
(r"aws\s+s3\s+rb\b", "aws s3 rb (bucket delete)"),
(r"aws\s+rds\s+delete-db", "aws rds delete-db (database deletion)"),
(r"aws\s+cloudformation\s+delete-stack", "aws cloudformation delete-stack"),
Expand Down
166 changes: 166 additions & 0 deletions test/hooks-command-safety.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
'use strict';

const fs = require('fs');
const path = require('path');

const hooksDir = path.join(__dirname, '../.claude/hooks');

describe('Claude Code command-safety hooks', () => {
describe('block_dangerous_commands.py — defense-in-depth', () => {
let content;

beforeAll(() => {
content = fs.readFileSync(path.join(hooksDir, 'block_dangerous_commands.py'), 'utf8');
});

test('should have shebang line', () => {
expect(content.startsWith('#!/usr/bin/env python3')).toBe(true);
});

test('should define DANGEROUS_PATTERNS list', () => {
expect(content).toContain('DANGEROUS_PATTERNS');
});

test('should block git force push (--force)', () => {
expect(content).toContain('git\\s+push\\s+[^|&;<>]*--force');
});

test('should block git force push (-f)', () => {
expect(content).toContain('git\\s+push\\s+[^|&;<>]*-f\\b');
});

test('should bound force-push wildcard to avoid chained-command false positives', () => {
// [^|&;<>]* stops at a pipe/redirect/separator so a later -f in a chained
// command or flattened heredoc body does not trigger a false positive.
expect(content).not.toContain('git\\s+push\\s+.*-f\\b');
});

test('should block git reset --hard', () => {
expect(content).toContain('git\\s+reset\\s+--hard');
});

test('should block rm -rf', () => {
// Pattern covers rm -rf, rm -fr, rm -r -f combinations
expect(content).toContain('rm\\s+-rf');
});

test('should block docker system prune', () => {
expect(content).toContain('docker\\s+system\\s+prune');
});

test('should block terraform destroy', () => {
expect(content).toContain('terraform\\s+destroy');
});

test('should block kubectl delete deployments', () => {
expect(content).toContain('kubectl\\s+delete');
});

test('should block AWS EC2 terminate', () => {
expect(content).toContain('aws\\s+ec2\\s+terminate-instances');
});

test('should block AWS S3 recursive delete', () => {
expect(content).toContain('aws\\s+s3\\s+rm\\s+[^|&;<>]*--recursive');
});

test('should block gcloud project deletion', () => {
expect(content).toContain('gcloud\\s+projects\\s+delete');
});

test('should block Azure resource group deletion', () => {
expect(content).toContain('az\\s+group\\s+delete');
});

test('should block npm publish', () => {
expect(content).toContain('npm\\s+publish\\b');
});

test('should block SQL DROP statements', () => {
expect(content).toContain('drop\\s+(database|table|schema');
});

test('should block Helm uninstall', () => {
expect(content).toContain('helm\\s+(uninstall|delete)');
});

test('should block Vercel production deploy', () => {
expect(content).toContain('vercel\\s+--prod\\b');
});

test('should bound all flag wildcards to a single command (no greedy \\s+.*)', () => {
// Flag patterns must use [^|&;<>]* instead of .* so a match cannot reach a
// flag-like token in a chained command, pipe, redirect or heredoc body
// (normalize() flattens newlines to spaces). See #794 and follow-up.
expect(content).not.toContain('\\s+.*');
});

test('should strip quoted string content to prevent quoted-separator bypass (Codex P1)', () => {
// Without this, --name 'a;b' would have ';' treated as a real boundary,
// allowing docker run --name 'a;b' --privileged ubuntu to bypass the check.
expect(content).toContain("re.sub(r\"'[^']*'\"");
expect(content).toContain('re.sub(r\'"[^"]*"\'');
});
Comment on lines +98 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test only validates source text, not hook behavior.

Line 98-103 passes even when bash -c "rm -rf /" bypasses detection. Add execution-level assertions for exit codes so quoted-payload regressions are caught.

Suggested behavioral regression test pattern
+const { spawnSync } = require('child_process');
+
+function runDangerousHook(command) {
+  const hook = path.join(hooksDir, 'block_dangerous_commands.py');
+  const input = JSON.stringify({ tool_input: { command } });
+  return spawnSync('python3', [hook], { input, encoding: 'utf8' });
+}
+
+test('blocks dangerous command inside quoted -c payload', () => {
+  const res = runDangerousHook('bash -c "rm -rf /tmp/demo"');
+  expect(res.status).toBe(2);
+});
+
+test('does not treat quoted separators as command boundaries', () => {
+  const res = runDangerousHook("docker run --name 'a;b' ubuntu:latest");
+  expect(res.status).toBe(0);
+});

As per coding guidelines, **/*.{js,ts,jsx,tsx,py,java,go,rb,php} should follow TDD methodology with meaningful coverage of behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/hooks-command-safety.test.js` around lines 98 - 103, The current test
'should strip quoted string content to prevent quoted-separator bypass (Codex
P1)' only inspects source text via the content variable; change it to invoke the
hook/command at runtime with crafted payloads (e.g., "--name 'a;b'" and "--name
\"$(bash -c 'echo vulnerable')\""/a direct bash -c payload) using the existing
test runner/helper that executes the hook (reuse whatever spawn/runHook helper
the suite uses) and assert process exit codes and outputs: assert non-zero exit
(or specific failure code) and that stdout/stderr contains the expected
detection/blocked message for both single- and double-quoted cases, so
regressions that bypass detection are caught. Ensure the test still checks for
the source-string replacements (content) but adds these execution-level
assertions for both quoted-separator and quoted-command payloads.


test('should exit 2 when dangerous command detected', () => {
expect(content).toContain('sys.exit(2)');
});

test('should exit 0 for safe commands', () => {
expect(content).toContain('sys.exit(0)');
});

test('should normalize command for matching (lowercase)', () => {
expect(content).toContain('.lower()');
});
});

describe('block_inline_secrets.py — inline credential protection', () => {
let content;

beforeAll(() => {
content = fs.readFileSync(path.join(hooksDir, 'block_inline_secrets.py'), 'utf8');
});

test('should have shebang line', () => {
expect(content.startsWith('#!/usr/bin/env python3')).toBe(true);
});

test('should define SECRET_PATTERNS list', () => {
expect(content).toContain('SECRET_PATTERNS');
});

test('should detect AWS access key ids (AKIA/ASIA)', () => {
expect(content).toContain('(AKIA|ASIA)[0-9A-Z]{16}');
});

test('should detect GitHub personal access tokens', () => {
expect(content).toContain('ghp_');
});

test('should detect Anthropic API keys', () => {
expect(content).toContain('sk-ant-');
});

test('should detect private key blocks', () => {
expect(content).toContain('PRIVATE KEY');
});

test('should whitelist the public Supabase demo JWT', () => {
expect(content).toContain('SUPABASE_DEMO_MARKER');
});

test('should exit 2 when an inline secret is detected', () => {
expect(content).toContain('sys.exit(2)');
});

test('should exit 0 for commands without inline secrets', () => {
expect(content).toContain('sys.exit(0)');
});

test('should reuse get_command from common', () => {
expect(content).toContain('from common import');
expect(content).toContain('get_command');
});
});
});
145 changes: 2 additions & 143 deletions test/hooks-integrity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,149 +90,8 @@ describe('Claude Code Hooks integrity', () => {
});
});

describe('block_dangerous_commands.py — defense-in-depth', () => {
let content;

beforeAll(() => {
content = fs.readFileSync(path.join(hooksDir, 'block_dangerous_commands.py'), 'utf8');
});

test('should have shebang line', () => {
expect(content.startsWith('#!/usr/bin/env python3')).toBe(true);
});

test('should define DANGEROUS_PATTERNS list', () => {
expect(content).toContain('DANGEROUS_PATTERNS');
});

test('should block git force push (--force)', () => {
expect(content).toContain('git\\s+push\\s+[^|&;<>]*--force');
});

test('should block git force push (-f)', () => {
expect(content).toContain('git\\s+push\\s+[^|&;<>]*-f\\b');
});

test('should bound force-push wildcard to avoid chained-command false positives', () => {
// [^|&;<>]* stops at a pipe/redirect/separator so a later -f in a chained
// command or flattened heredoc body does not trigger a false positive.
expect(content).not.toContain('git\\s+push\\s+.*-f\\b');
});

test('should block git reset --hard', () => {
expect(content).toContain('git\\s+reset\\s+--hard');
});

test('should block rm -rf', () => {
// Pattern covers rm -rf, rm -fr, rm -r -f combinations
expect(content).toContain('rm\\s+-rf');
});

test('should block docker system prune', () => {
expect(content).toContain('docker\\s+system\\s+prune');
});

test('should block terraform destroy', () => {
expect(content).toContain('terraform\\s+destroy');
});

test('should block kubectl delete deployments', () => {
expect(content).toContain('kubectl\\s+delete');
});

test('should block AWS EC2 terminate', () => {
expect(content).toContain('aws\\s+ec2\\s+terminate-instances');
});

test('should block AWS S3 recursive delete', () => {
expect(content).toContain('aws\\s+s3\\s+rm\\s+.*--recursive');
});

test('should block gcloud project deletion', () => {
expect(content).toContain('gcloud\\s+projects\\s+delete');
});

test('should block Azure resource group deletion', () => {
expect(content).toContain('az\\s+group\\s+delete');
});

test('should block npm publish', () => {
expect(content).toContain('npm\\s+publish\\b');
});

test('should block SQL DROP statements', () => {
expect(content).toContain('drop\\s+(database|table|schema');
});

test('should block Helm uninstall', () => {
expect(content).toContain('helm\\s+(uninstall|delete)');
});

test('should block Vercel production deploy', () => {
expect(content).toContain('vercel\\s+--prod\\b');
});

test('should exit 2 when dangerous command detected', () => {
expect(content).toContain('sys.exit(2)');
});

test('should exit 0 for safe commands', () => {
expect(content).toContain('sys.exit(0)');
});

test('should normalize command for matching (lowercase)', () => {
expect(content).toContain('.lower()');
});
});

describe('block_inline_secrets.py — inline credential protection', () => {
let content;

beforeAll(() => {
content = fs.readFileSync(path.join(hooksDir, 'block_inline_secrets.py'), 'utf8');
});

test('should have shebang line', () => {
expect(content.startsWith('#!/usr/bin/env python3')).toBe(true);
});

test('should define SECRET_PATTERNS list', () => {
expect(content).toContain('SECRET_PATTERNS');
});

test('should detect AWS access key ids (AKIA/ASIA)', () => {
expect(content).toContain('(AKIA|ASIA)[0-9A-Z]{16}');
});

test('should detect GitHub personal access tokens', () => {
expect(content).toContain('ghp_');
});

test('should detect Anthropic API keys', () => {
expect(content).toContain('sk-ant-');
});

test('should detect private key blocks', () => {
expect(content).toContain('PRIVATE KEY');
});

test('should whitelist the public Supabase demo JWT', () => {
expect(content).toContain('SUPABASE_DEMO_MARKER');
});

test('should exit 2 when an inline secret is detected', () => {
expect(content).toContain('sys.exit(2)');
});

test('should exit 0 for commands without inline secrets', () => {
expect(content).toContain('sys.exit(0)');
});

test('should reuse get_command from common', () => {
expect(content).toContain('from common import');
expect(content).toContain('get_command');
});
});
// block_dangerous_commands.py and block_inline_secrets.py are covered in
// test/hooks-command-safety.test.js (kept separate to respect file-length).

describe('block_git_no_verify.py — commit hook protection', () => {
let content;
Expand Down