@paulduvall/claude-dev-toolkit 0.0.1-alpha.19 → 0.0.1-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ {
2
+ "thresholds": {
3
+ "max_complexity": 10,
4
+ "max_function_lines": 20,
5
+ "max_nesting_depth": 3,
6
+ "max_parameters": 4,
7
+ "max_file_lines": 300,
8
+ "duplicate_min_lines": 4
9
+ },
10
+ "security": {
11
+ "enabled": true,
12
+ "trojan_enabled": true
13
+ },
14
+ "suppress_files": [
15
+ "tests/**",
16
+ "*_test.py",
17
+ "*.generated.*"
18
+ ]
19
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: verify GPG/SSH commit signing before git commit.
3
+
4
+ Reads a PreToolUse JSON event from stdin. If the Bash command is a
5
+ git commit, verifies that commit signing is configured. Blocks with
6
+ remediation instructions if not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+
16
+ _GIT_COMMIT_RE = re.compile(r"\bgit\s+commit\b")
17
+
18
+
19
+ def _is_git_commit(command: str) -> bool:
20
+ """Return True if command contains a git commit invocation."""
21
+ return bool(_GIT_COMMIT_RE.search(command))
22
+
23
+
24
+ def _git_config(key: str) -> str:
25
+ """Read a git config value, returning empty string on failure."""
26
+ try:
27
+ result = subprocess.run(
28
+ ["git", "config", "--get", key],
29
+ capture_output=True, text=True, timeout=5,
30
+ )
31
+ return result.stdout.strip()
32
+ except (subprocess.SubprocessError, OSError):
33
+ return ""
34
+
35
+
36
+ def _check_gpg_signing() -> str | None:
37
+ """Verify GPG signing config. Return error message or None."""
38
+ gpg_format = _git_config("gpg.format")
39
+ if gpg_format == "ssh":
40
+ return _check_ssh_signing()
41
+ program = _git_config("gpg.program")
42
+ if not program:
43
+ return "gpg.program is not configured"
44
+ return None
45
+
46
+
47
+ def _check_ssh_signing() -> str | None:
48
+ """Verify SSH signing config. Return error message or None."""
49
+ allowed = _git_config("gpg.ssh.allowedSignersFile")
50
+ if not allowed:
51
+ return "gpg.ssh.allowedSignersFile is not configured"
52
+ return None
53
+
54
+
55
+ _REMEDIATION_TEMPLATE = """\
56
+ Commit signing is not properly configured: {error}
57
+
58
+ IMPORTANT: Ask the user which option they prefer before proceeding.
59
+
60
+ **Option 1 -- GPG signing** (traditional, widely supported):
61
+ ```
62
+ git config --global commit.gpgsign true
63
+ git config --global gpg.program gpg
64
+ ```
65
+ Requires: GPG installed, a GPG key generated (`gpg --gen-key`)
66
+
67
+ **Option 2 -- SSH signing** (simpler, uses existing SSH keys):
68
+
69
+ Step 1 -- Generate an SSH key (skip if ~/.ssh/id_ed25519.pub exists):
70
+ ```
71
+ ssh-keygen -t ed25519 -C "your_email@example.com"
72
+ ```
73
+
74
+ Step 2 -- Configure git to use SSH signing:
75
+ ```
76
+ git config --global commit.gpgsign true
77
+ git config --global gpg.format ssh
78
+ git config --global user.signingkey ~/.ssh/id_ed25519.pub
79
+ ```
80
+
81
+ Step 3 -- Create the allowed signers file:
82
+ ```
83
+ echo "$(git config --get user.email) namespaces=\\"git\\" $(cat ~/.ssh/id_ed25519.pub)" > ~/.ssh/allowed_signers
84
+ git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
85
+ ```
86
+
87
+ Step 4 -- Add the key to GitHub as a **signing key**:
88
+ ```
89
+ gh ssh-key add ~/.ssh/id_ed25519.pub --type signing
90
+ ```
91
+ (Or manually: GitHub -> Settings -> SSH and GPG keys -> New SSH key -> \
92
+ Key type: **Signing Key**)
93
+
94
+ Ask the user: "Commit signing isn't configured. Would you like me to \
95
+ set it up for you? I can configure GPG or SSH signing -- which do you \
96
+ prefer?" Then run the commands for their chosen option.\
97
+ """
98
+
99
+
100
+ def _build_remediation(error: str) -> str:
101
+ """Build a user-friendly remediation message."""
102
+ return _REMEDIATION_TEMPLATE.format(error=error)
103
+
104
+
105
+ def main() -> None:
106
+ """Entry point: check commit signing config before git commit."""
107
+ try:
108
+ event = json.load(sys.stdin)
109
+ except (json.JSONDecodeError, EOFError):
110
+ sys.exit(0)
111
+ command = event.get("tool_input", {}).get("command", "")
112
+ if not _is_git_commit(command):
113
+ sys.exit(0)
114
+ gpgsign = _git_config("commit.gpgsign")
115
+ if gpgsign != "true":
116
+ reason = _build_remediation("commit.gpgsign is not enabled")
117
+ print(json.dumps({"decision": "block", "reason": reason}))
118
+ sys.exit(0)
119
+ error = _check_gpg_signing()
120
+ if error:
121
+ reason = _build_remediation(error)
122
+ print(json.dumps({"decision": "block", "reason": reason}))
123
+ sys.exit(0)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: detect common code smells in modified files.
3
+
4
+ Delegates all analysis to smell_checks module. Reads the PostToolUse
5
+ JSON event from stdin, checks the written file, and emits a blocking
6
+ result when violations are found.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ # Allow importing sibling module from the same directory.
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+
18
+ from smell_checks import check_file, format_violations # noqa: E402
19
+
20
+
21
+ def main() -> None:
22
+ """Entry point: read PostToolUse event, check file, emit result."""
23
+ try:
24
+ event = json.load(sys.stdin)
25
+ except (json.JSONDecodeError, EOFError):
26
+ sys.exit(0)
27
+ file_path = event.get("tool_input", {}).get("file_path", "")
28
+ if not file_path or not os.path.isfile(file_path):
29
+ sys.exit(0)
30
+ smells = check_file(file_path)
31
+ if smells:
32
+ reason = format_violations(file_path, smells)
33
+ print(json.dumps({"decision": "block", "reason": reason}))
34
+ sys.exit(0)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: detect security violations in modified files.
3
+
4
+ Delegates analysis to security_checks module. Reads the PostToolUse
5
+ JSON event from stdin, checks the written file, and emits a blocking
6
+ result when violations are found.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ from security_checks import check_security, format_security_violations # noqa: E402
18
+
19
+
20
+ def main() -> None:
21
+ """Entry point: read PostToolUse event, check file, emit result."""
22
+ try:
23
+ event = json.load(sys.stdin)
24
+ except (json.JSONDecodeError, EOFError):
25
+ sys.exit(0)
26
+ file_path = event.get("tool_input", {}).get("file_path", "")
27
+ if not file_path or not os.path.isfile(file_path):
28
+ sys.exit(0)
29
+ smells = check_security(file_path)
30
+ if smells:
31
+ reason = format_security_violations(file_path, smells)
32
+ print(json.dumps({"decision": "block", "reason": reason}))
33
+ sys.exit(0)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,29 @@
1
+ #!/bin/bash
2
+ # claude-wrapper.sh - Shell wrapper for claude that manages iTerm2 tab colors
3
+ #
4
+ # Source this file in ~/.zshrc:
5
+ # source ~/Code/claude-code/hooks/claude-wrapper.sh
6
+ #
7
+ # Sets gray tab color on launch, red on non-zero exit, then resets.
8
+ # Mid-session colors (blue=working, green=done) are handled by Claude Code hooks.
9
+ # See ~/.claude/settings.json and tab-color.sh
10
+
11
+ CCDK_HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
12
+
13
+ claude() {
14
+ # Set initial tab color to gray (idle, no prompt running yet)
15
+ "$CCDK_HOOKS_DIR/tab-color.sh" gray < /dev/null
16
+
17
+ # Pass all args through to the real claude binary
18
+ command claude "$@"
19
+ local exit_code=$?
20
+
21
+ if [ $exit_code -ne 0 ]; then
22
+ "$CCDK_HOOKS_DIR/tab-color.sh" red < /dev/null
23
+ fi
24
+
25
+ # Reset tab color to default
26
+ "$CCDK_HOOKS_DIR/tab-color.sh" reset < /dev/null
27
+
28
+ return $exit_code
29
+ }
@@ -0,0 +1,110 @@
1
+ """Per-project configuration loader for code smell and security hooks.
2
+
3
+ Searches for .smellrc.json walking up from the target file's directory.
4
+ Falls back to defaults matching the hardcoded thresholds in smell_types.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from functools import lru_cache
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Config:
18
+ """Immutable configuration for all hooks."""
19
+
20
+ max_complexity: int = 10
21
+ max_function_lines: int = 20
22
+ max_nesting_depth: int = 3
23
+ max_parameters: int = 4
24
+ max_file_lines: int = 300
25
+ duplicate_min_lines: int = 4
26
+ security_enabled: bool = True
27
+ trojan_enabled: bool = True
28
+ suppress_files: tuple[str, ...] = ()
29
+
30
+
31
+ DEFAULT_CONFIG = Config()
32
+
33
+
34
+ def _find_config_file(start_dir: str) -> str | None:
35
+ """Walk up from start_dir looking for .smellrc.json."""
36
+ current = os.path.abspath(start_dir)
37
+ root = os.path.dirname(current)
38
+ while current != root:
39
+ candidate = os.path.join(current, ".smellrc.json")
40
+ if os.path.isfile(candidate):
41
+ return candidate
42
+ root = current
43
+ current = os.path.dirname(current)
44
+ return None
45
+
46
+
47
+ def _parse_config(path: str) -> Config:
48
+ """Parse a .smellrc.json file into a Config object."""
49
+ with open(path, "r", encoding="utf-8") as fh:
50
+ raw = json.load(fh)
51
+ thresholds = raw.get("thresholds", {})
52
+ security = raw.get("security", {})
53
+ suppress = raw.get("suppress_files", [])
54
+ return Config(
55
+ max_complexity=thresholds.get("max_complexity", DEFAULT_CONFIG.max_complexity),
56
+ max_function_lines=thresholds.get("max_function_lines", DEFAULT_CONFIG.max_function_lines),
57
+ max_nesting_depth=thresholds.get("max_nesting_depth", DEFAULT_CONFIG.max_nesting_depth),
58
+ max_parameters=thresholds.get("max_parameters", DEFAULT_CONFIG.max_parameters),
59
+ max_file_lines=thresholds.get("max_file_lines", DEFAULT_CONFIG.max_file_lines),
60
+ duplicate_min_lines=thresholds.get("duplicate_min_lines", DEFAULT_CONFIG.duplicate_min_lines),
61
+ security_enabled=security.get("enabled", DEFAULT_CONFIG.security_enabled),
62
+ trojan_enabled=security.get("trojan_enabled", DEFAULT_CONFIG.trojan_enabled),
63
+ suppress_files=tuple(suppress),
64
+ )
65
+
66
+
67
+ @lru_cache(maxsize=32)
68
+ def _cached_load(config_path: str) -> Config:
69
+ """Load and cache a config file by its resolved path."""
70
+ return _parse_config(config_path)
71
+
72
+
73
+ def load_config(file_path: str) -> Config:
74
+ """Load config for a given source file.
75
+
76
+ Args:
77
+ file_path: Path to the source file being checked.
78
+
79
+ Returns:
80
+ Config from nearest .smellrc.json, or DEFAULT_CONFIG.
81
+ """
82
+ start = os.path.dirname(os.path.abspath(file_path))
83
+ config_path = _find_config_file(start)
84
+ if config_path is None:
85
+ return DEFAULT_CONFIG
86
+ try:
87
+ return _cached_load(config_path)
88
+ except (json.JSONDecodeError, OSError, KeyError, TypeError):
89
+ return DEFAULT_CONFIG
90
+
91
+
92
+ def is_file_suppressed(file_path: str, config: Config) -> bool:
93
+ """Check if a file matches any suppress_files glob patterns.
94
+
95
+ Args:
96
+ file_path: Path to check against suppression globs.
97
+ config: Config with suppress_files patterns.
98
+
99
+ Returns:
100
+ True if the file should be skipped.
101
+ """
102
+ if not config.suppress_files:
103
+ return False
104
+ basename = os.path.basename(file_path)
105
+ for pattern in config.suppress_files:
106
+ if fnmatch.fnmatch(basename, pattern):
107
+ return True
108
+ if fnmatch.fnmatch(file_path, pattern):
109
+ return True
110
+ return False
@@ -0,0 +1,177 @@
1
+ """Python AST-based security checks inspired by Bandit.
2
+
3
+ Detects common security anti-patterns without external dependencies:
4
+ B101 (assert), B102 (exec/eval), B105/B106 (hardcoded passwords),
5
+ B110 (try-except-pass), B301 (pickle), B602 (subprocess shell=True).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+
13
+ from smell_types import FIXES, Smell
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Individual check functions
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _PASSWORD_NAMES = frozenset((
20
+ "password", "passwd", "pwd", "secret", "token", "api_key",
21
+ ))
22
+
23
+
24
+ def _is_test_file(file_path: str) -> bool:
25
+ """Return True if file looks like a test file."""
26
+ base = os.path.basename(file_path)
27
+ return base.startswith("test_") or base.endswith("_test.py")
28
+
29
+
30
+ def _check_assert(node: ast.Assert, file_path: str) -> Smell | None:
31
+ """B101: assert used outside test files."""
32
+ if _is_test_file(file_path):
33
+ return None
34
+ return Smell(
35
+ "B101", "assert", node.lineno,
36
+ "assert used in non-test code",
37
+ FIXES["B101"],
38
+ )
39
+
40
+
41
+ def _check_exec_eval(node: ast.Call) -> Smell | None:
42
+ """B102: exec() or eval() call detected."""
43
+ func = node.func
44
+ if isinstance(func, ast.Name) and func.id in ("exec", "eval"):
45
+ return Smell(
46
+ "B102", func.id, node.lineno,
47
+ f"{func.id}() call detected",
48
+ FIXES["B102"],
49
+ )
50
+ return None
51
+
52
+
53
+ def _check_hardcoded_password(node: ast.Assign) -> Smell | None:
54
+ """B105/B106: hardcoded string assigned to password-like variable."""
55
+ if not isinstance(node.value, ast.Constant):
56
+ return None
57
+ if not isinstance(node.value.value, str):
58
+ return None
59
+ if not node.value.value or len(node.value.value) < 2:
60
+ return None
61
+ for target in node.targets:
62
+ name = _extract_name(target)
63
+ if name and name.lower() in _PASSWORD_NAMES:
64
+ return Smell(
65
+ "B105", name, node.lineno,
66
+ f"hardcoded value assigned to '{name}'",
67
+ FIXES["B105"],
68
+ )
69
+ return None
70
+
71
+
72
+ def _extract_name(node: ast.AST) -> str | None:
73
+ """Extract variable name from an assignment target."""
74
+ if isinstance(node, ast.Name):
75
+ return node.id
76
+ if isinstance(node, ast.Attribute):
77
+ return node.attr
78
+ return None
79
+
80
+
81
+ def _check_except_pass(node: ast.ExceptHandler) -> Smell | None:
82
+ """B110: except block that only contains pass."""
83
+ if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
84
+ return Smell(
85
+ "B110", "except-pass", node.lineno,
86
+ "except block silently passes",
87
+ FIXES["B110"],
88
+ )
89
+ return None
90
+
91
+
92
+ def _check_pickle(node: ast.Call) -> Smell | None:
93
+ """B301: pickle.loads/load call detected."""
94
+ func = node.func
95
+ if not isinstance(func, ast.Attribute):
96
+ return None
97
+ if func.attr not in ("load", "loads"):
98
+ return None
99
+ if isinstance(func.value, ast.Name) and func.value.id == "pickle":
100
+ return Smell(
101
+ "B301", "pickle", node.lineno,
102
+ f"pickle.{func.attr}() used on potentially untrusted data",
103
+ FIXES["B301"],
104
+ )
105
+ return None
106
+
107
+
108
+ def _check_subprocess_shell(node: ast.Call) -> Smell | None:
109
+ """B602: subprocess call with shell=True."""
110
+ func = node.func
111
+ if not isinstance(func, ast.Attribute):
112
+ return None
113
+ if func.attr not in ("call", "run", "Popen", "check_output"):
114
+ return None
115
+ if not (isinstance(func.value, ast.Name) and func.value.id == "subprocess"):
116
+ return None
117
+ for kw in node.keywords:
118
+ if kw.arg == "shell" and _is_true_constant(kw.value):
119
+ return Smell(
120
+ "B602", f"subprocess.{func.attr}", node.lineno,
121
+ "subprocess call with shell=True",
122
+ FIXES["B602"],
123
+ )
124
+ return None
125
+
126
+
127
+ def _is_true_constant(node: ast.AST) -> bool:
128
+ """Return True if the node is the constant True."""
129
+ return isinstance(node, ast.Constant) and node.value is True
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Public API
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def check_bandit(file_path: str, source: str) -> list[Smell]:
137
+ """Run AST-based security checks on Python source.
138
+
139
+ Args:
140
+ file_path: Path for context (test file detection).
141
+ source: Python source code string.
142
+
143
+ Returns:
144
+ List of detected security violations.
145
+ """
146
+ try:
147
+ tree = ast.parse(source, filename=file_path)
148
+ except SyntaxError:
149
+ return []
150
+ smells: list[Smell] = []
151
+ for node in ast.walk(tree):
152
+ smell = _dispatch_check(node, file_path)
153
+ if smell is not None:
154
+ smells.append(smell)
155
+ return smells
156
+
157
+
158
+ def _dispatch_check(node: ast.AST, file_path: str) -> Smell | None:
159
+ """Route an AST node to the appropriate security check."""
160
+ if isinstance(node, ast.Assert):
161
+ return _check_assert(node, file_path)
162
+ if isinstance(node, ast.Call):
163
+ return _check_call(node)
164
+ if isinstance(node, ast.Assign):
165
+ return _check_hardcoded_password(node)
166
+ if isinstance(node, ast.ExceptHandler):
167
+ return _check_except_pass(node)
168
+ return None
169
+
170
+
171
+ def _check_call(node: ast.Call) -> Smell | None:
172
+ """Run all Call-node security checks, return first match."""
173
+ for checker in (_check_exec_eval, _check_pickle, _check_subprocess_shell):
174
+ result = checker(node)
175
+ if result is not None:
176
+ return result
177
+ return None
@@ -0,0 +1,97 @@
1
+ """Security check orchestrator -- runs secrets, bandit, and trojan checks.
2
+
3
+ Reads a source file, runs applicable security checks, and returns
4
+ Smell objects for any violations. Respects per-project config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from config import Config, load_config, is_file_suppressed
12
+ from security_bandit import check_bandit
13
+ from security_secrets import check_secrets
14
+ from security_trojan import check_trojan
15
+ from smell_types import Smell
16
+ from suppression import filter_suppressed
17
+
18
+ PYTHON_EXTS = frozenset((".py",))
19
+ ALL_EXTS = frozenset((
20
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".go",
21
+ ".rs", ".c", ".cpp", ".cs", ".rb", ".sh", ".yaml", ".yml",
22
+ ".json", ".toml", ".env", ".cfg", ".ini", ".conf",
23
+ ))
24
+ SKIP_DIRS = frozenset((
25
+ "node_modules", "__pycache__", ".git", "dist", "build", ".next",
26
+ ))
27
+
28
+
29
+ def _should_skip(file_path: str) -> bool:
30
+ """Return True for paths in ignored directories."""
31
+ parts = set(file_path.replace("\\", "/").split("/"))
32
+ return bool(SKIP_DIRS & parts)
33
+
34
+
35
+ def _read_source(file_path: str) -> str | None:
36
+ """Read file contents, returning None on failure."""
37
+ try:
38
+ with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
39
+ return fh.read()
40
+ except OSError:
41
+ return None
42
+
43
+
44
+ def _run_checks(file_path: str, source: str, config: Config) -> list[Smell]:
45
+ """Run applicable security checks on file contents."""
46
+ lines = source.splitlines()
47
+ smells = _collect_violations(file_path, source, lines, config)
48
+ return filter_suppressed(smells, lines, "security")
49
+
50
+
51
+ def _collect_violations(
52
+ file_path: str, source: str, lines: list[str], config: Config,
53
+ ) -> list[Smell]:
54
+ """Gather raw violations before suppression filtering."""
55
+ smells: list[Smell] = []
56
+ if config.security_enabled:
57
+ smells.extend(check_secrets(lines))
58
+ ext = os.path.splitext(file_path)[1]
59
+ if ext in PYTHON_EXTS and config.security_enabled:
60
+ smells.extend(check_bandit(file_path, source))
61
+ if config.trojan_enabled:
62
+ smells.extend(check_trojan(lines))
63
+ return smells
64
+
65
+
66
+ def _is_excluded(file_path: str, ext: str, config: Config) -> bool:
67
+ """Return True if file should be excluded from security checks."""
68
+ if ext not in ALL_EXTS or _should_skip(file_path):
69
+ return True
70
+ return is_file_suppressed(file_path, config)
71
+
72
+
73
+ def check_security(file_path: str, config: Config | None = None) -> list[Smell]:
74
+ """Run all security checks on a single file."""
75
+ if config is None:
76
+ config = load_config(file_path)
77
+ ext = os.path.splitext(file_path)[1]
78
+ if _is_excluded(file_path, ext, config):
79
+ return []
80
+ source = _read_source(file_path)
81
+ if source is None:
82
+ return []
83
+ return _run_checks(file_path, source, config)
84
+
85
+
86
+ def format_security_violations(file_path: str, smells: list[Smell]) -> str:
87
+ """Build the feedback message for security violations."""
88
+ header = f"\nSECURITY VIOLATIONS in {file_path}:"
89
+ details = []
90
+ for s in smells:
91
+ tag = s.kind.upper().replace("_", " ")
92
+ details.append(f" [{tag}] {s.name} line {s.line}: {s.detail}")
93
+ fixes = ["\nFix these security issues before moving on:"]
94
+ for fix in dict.fromkeys(s.fix for s in smells):
95
+ fixes.append(f" - {fix}")
96
+ fixes.append("Then notify the user what you fixed and why.")
97
+ return "\n".join([header, *details, *fixes])