deadpush 0.2.0__py3-none-any.whl

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.
deadpush/hooks.py ADDED
@@ -0,0 +1,369 @@
1
+ """
2
+ Extension Hooks for deadpush AI Agent Guardian.
3
+
4
+ This module provides clean extension points for future integrations with
5
+ Cursor, Claude Code, Windsurf, and other AI coding environments.
6
+
7
+ Currently a foundation. Future versions can expose:
8
+ - Pre-file-creation hooks
9
+ - Pre-git-commit hooks (beyond current pre-push)
10
+ - Custom rule engines
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+
22
+ class HookRegistry:
23
+ """Simple hook registry for extensibility."""
24
+
25
+ def __init__(self):
26
+ self._hooks: dict[str, list[Callable]] = {}
27
+
28
+ def register(self, event: str, func: Callable):
29
+ if event not in self._hooks:
30
+ self._hooks[event] = []
31
+ self._hooks[event].append(func)
32
+
33
+ def trigger(self, event: str, *args, **kwargs) -> list[Any]:
34
+ results = []
35
+ for func in self._hooks.get(event, []):
36
+ try:
37
+ results.append(func(*args, **kwargs))
38
+ except Exception as e:
39
+ print(f"Hook {event} failed: {e}")
40
+ return results
41
+
42
+
43
+ # Global registry instance
44
+ hooks = HookRegistry()
45
+
46
+ # Example usage in future:
47
+ # hooks.register("before_create_file", my_custom_check)
48
+ # hooks.trigger("before_create_file", filepath)
49
+
50
+
51
+ def run_precommit_guardrails(repo_root: Path) -> tuple[bool, list[dict[str, Any]]]:
52
+ """Run guardrails on staged files. Returns (passed, violations)."""
53
+ import subprocess
54
+ from .intercept import (
55
+ _check_prompt_injection,
56
+ _check_hardcoded_secrets,
57
+ _check_security,
58
+ _check_debris_patterns,
59
+ _check_layer_violations,
60
+ _check_dependency_integrity,
61
+ )
62
+ from .config import load_config
63
+ from .rules import RuntimeConfig
64
+
65
+ config = load_config(explicit_root=repo_root)
66
+ runtime = RuntimeConfig(repo_root)
67
+ violations: list[dict[str, Any]] = []
68
+
69
+ # Get staged files
70
+ try:
71
+ result = subprocess.run(
72
+ ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
73
+ capture_output=True, text=True, check=False, timeout=10,
74
+ cwd=repo_root,
75
+ )
76
+ if result.returncode != 0:
77
+ return True, []
78
+ staged = [l.strip() for l in result.stdout.splitlines() if l.strip()]
79
+ except Exception:
80
+ return True, []
81
+
82
+ for rel_path in staged:
83
+ # Only check text files we understand
84
+ ext = Path(rel_path).suffix.lower()
85
+ if ext not in (".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".rb", ".php", ".sh", ".bash", ".yaml", ".yml", ".json", ".toml", ".md"):
86
+ continue
87
+
88
+ # Get staged content
89
+ try:
90
+ content = subprocess.run(
91
+ ["git", "show", f":{rel_path}"],
92
+ capture_output=True, text=True, check=False, timeout=5,
93
+ cwd=repo_root,
94
+ )
95
+ if content.returncode != 0:
96
+ continue
97
+ source = content.stdout
98
+ except Exception:
99
+ continue
100
+
101
+ # Run guardrails
102
+ for check_fn, category in [
103
+ (_check_prompt_injection, "prompt_injection"),
104
+ (_check_hardcoded_secrets, "secret"),
105
+ (_check_security, "security"),
106
+ ]:
107
+ for v in check_fn(source, runtime):
108
+ violations.append({
109
+ "file": rel_path,
110
+ "line": v.line,
111
+ "category": category,
112
+ "description": v.description,
113
+ "severity": v.severity,
114
+ })
115
+
116
+ for v in _check_debris_patterns(source, ext, runtime):
117
+ violations.append({
118
+ "file": rel_path,
119
+ "line": v.line,
120
+ "category": "debris",
121
+ "description": v.description,
122
+ "severity": v.severity,
123
+ })
124
+
125
+ try:
126
+ for v in _check_layer_violations(source, rel_path, config, runtime):
127
+ violations.append({
128
+ "file": rel_path,
129
+ "line": v.line,
130
+ "category": "layer",
131
+ "description": v.description,
132
+ "severity": v.severity,
133
+ })
134
+ except Exception:
135
+ pass
136
+
137
+ try:
138
+ for v in _check_dependency_integrity(source, rel_path, repo_root, runtime):
139
+ violations.append({
140
+ "file": rel_path,
141
+ "line": v.line,
142
+ "category": "dependency",
143
+ "description": v.description,
144
+ "severity": v.severity,
145
+ })
146
+ except Exception:
147
+ pass
148
+
149
+ if violations:
150
+ print(f"\ndeadpush — Pre-commit guardrails found {len(violations)} violation(s):\n")
151
+ for v in violations:
152
+ icon = {"critical": "🔴", "high": "⚠️", "medium": "⚡", "low": "ℹ️"}.get(v["severity"], "⚡")
153
+ print(f" {icon} {v['file']}:{v['line']} [{v['category']}] {v['description'][:100]}")
154
+ print("")
155
+ print("Commit blocked. Fix the violations above or use `git commit --no-verify` to skip.")
156
+ return False, violations
157
+
158
+ return True, []
159
+
160
+
161
+ def install_hook(repo_root: Path) -> None:
162
+ """
163
+ Install a cross-platform pre-push git hook.
164
+
165
+ The hook runs `deadpush scan --format summary` (via the current Python
166
+ to avoid PATH/venv issues) and blocks the push if blocking debris or
167
+ dead symbols are found.
168
+
169
+ This version uses a Python script instead of Bash so it works on:
170
+ - Windows (PowerShell, CMD, Git for Windows without Git Bash)
171
+ - macOS / Linux
172
+ - Any environment where Python can run the deadpush module.
173
+
174
+ Idempotent.
175
+ """
176
+ hooks_dir = repo_root / ".git" / "hooks"
177
+ if not hooks_dir.exists():
178
+ # Not a git repo or hooks dir missing
179
+ raise RuntimeError(f"No .git/hooks directory in {repo_root}")
180
+
181
+ hook_path = hooks_dir / "pre-push"
182
+
183
+ # Capture the exact Python at install time. This is crucial for venvs on
184
+ # Windows (PowerShell/CMD) where the "deadpush" entrypoint may not be in
185
+ # PATH for the shell that Git uses to invoke hooks.
186
+ python_exe = sys.executable
187
+
188
+ # Cross-platform Python hook.
189
+ # We hardcode the python we were installed with + -m deadpush.cli.
190
+ script = f'''#!/usr/bin/env python3
191
+ """
192
+ deadpush pre-push git hook (installed by deadpush protect).
193
+
194
+ Cross-platform (Windows PowerShell/CMD + Git for Windows, macOS, Linux).
195
+ Runs the scan via the Python that was used to run "deadpush protect".
196
+ """
197
+ import subprocess
198
+ import sys
199
+
200
+ def main():
201
+ try:
202
+ # Hardcoded at install time for maximum reliability across shells
203
+ cmd = [r"{python_exe}", "-m", "deadpush.cli", "scan", "--format", "summary"]
204
+ result = subprocess.run(
205
+ cmd,
206
+ capture_output=True,
207
+ text=True,
208
+ check=False
209
+ )
210
+ output = (result.stdout or "") + (result.stderr or "")
211
+ print(output)
212
+
213
+ # Block if we see debris or dead symbols (unless the count is explicitly 0)
214
+ has_debris = "debris" in output and "0 debris" not in output
215
+ has_dead = "dead symbols" in output and "0 dead symbols" not in output
216
+
217
+ if has_debris or has_dead:
218
+ print("deadpush check found blocking issues.")
219
+ print("Run 'deadpush scan' for details. Use --force to override (not recommended).")
220
+ sys.exit(1)
221
+
222
+ except FileNotFoundError:
223
+ print("deadpush not available (Python module could not be found).")
224
+ print("Skipping hook (install with pip install -e . in the deadpush source).")
225
+ sys.exit(0) # Do not block the push on hook setup problems
226
+ except Exception as e:
227
+ print(f"deadpush hook encountered an error: {{e}}")
228
+ sys.exit(0) # Never block the push because the hook itself is broken
229
+
230
+ if __name__ == "__main__":
231
+ main()
232
+ '''
233
+
234
+ hook_path.write_text(script, encoding="utf-8")
235
+
236
+ # Make it executable on Unix-like systems (harmless on Windows)
237
+ try:
238
+ hook_path.chmod(0o755)
239
+ except Exception:
240
+ pass
241
+
242
+ print(f"Installed cross-platform pre-push hook at {hook_path}")
243
+ print(" (Works in PowerShell, CMD, Git Bash, macOS, Linux, etc. — uses the exact Python from when you ran 'deadpush protect')")
244
+
245
+ # On Windows, also install a tiny .cmd shim.
246
+ # Git for Windows will often execute .cmd hooks preferentially when
247
+ # the user is in PowerShell or CMD, avoiding any shebang/execution issues.
248
+ if os.name == "nt":
249
+ cmd_shim = hooks_dir / "pre-push.cmd"
250
+ # Use the exact python that was used to install deadpush (works great with venvs)
251
+ shim_content = f'''@echo off
252
+ "{python_exe}" "{hook_path}" %*
253
+ '''
254
+ cmd_shim.write_text(shim_content, encoding="utf-8")
255
+ print(f" Also installed Windows shim at {cmd_shim}")
256
+
257
+
258
+ def install_precommit_hook(repo_root: Path) -> None:
259
+ """
260
+ Install a pre-commit git hook that runs guardrails on staged files.
261
+
262
+ Blocks commits containing:
263
+ - Prompt injection / AI override attempts
264
+ - Hardcoded secrets (API keys, tokens, passwords)
265
+ - Security violations (eval, exec, subprocess)
266
+ - Architecture layer violations
267
+
268
+ Uses a Python script for cross-platform support.
269
+ """
270
+ hooks_dir = repo_root / ".git" / "hooks"
271
+ if not hooks_dir.exists():
272
+ raise RuntimeError(f"No .git/hooks directory in {repo_root}")
273
+
274
+ hook_path = hooks_dir / "pre-commit"
275
+ python_exe = sys.executable
276
+
277
+ script = f'''#!/usr/bin/env python3
278
+ """
279
+ deadpush pre-commit guardrails (installed by deadpush hook install-precommit).
280
+
281
+ Blocks commits with prompt injection, hardcoded secrets,
282
+ security violations, and architecture layer violations.
283
+ """
284
+ import subprocess
285
+ import sys
286
+
287
+ def main():
288
+ try:
289
+ cmd = [r"{python_exe}", "-m", "deadpush.cli", "hooks", "run-precommit"]
290
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
291
+ if result.stdout:
292
+ print(result.stdout)
293
+ if result.stderr:
294
+ print(result.stderr, file=sys.stderr)
295
+ if result.returncode != 0:
296
+ print("deadpush guardrails blocked this commit.")
297
+ sys.exit(1)
298
+ except FileNotFoundError:
299
+ print("deadpush not available (Python module could not be found).")
300
+ sys.exit(0)
301
+
302
+ if __name__ == "__main__":
303
+ main()
304
+ '''
305
+
306
+ hook_path.write_text(script, encoding="utf-8")
307
+ try:
308
+ hook_path.chmod(0o755)
309
+ except Exception:
310
+ pass
311
+
312
+ print(f"Installed pre-commit guardrail hook at {hook_path}")
313
+
314
+ if os.name == "nt":
315
+ cmd_shim = hooks_dir / "pre-commit.cmd"
316
+ shim_content = f'''@echo off
317
+ "{python_exe}" "{hook_path}" %*
318
+ '''
319
+ cmd_shim.write_text(shim_content, encoding="utf-8")
320
+ print(f" Also installed Windows shim at {cmd_shim}")
321
+
322
+
323
+ def setup_mcp_discovery(repo_root: Path) -> None:
324
+ """Create MCP config files so agents discover deadpush automatically.
325
+
326
+ Creates .cursor/mcp.json and .vscode/mcp.json so Cursor, VS Code,
327
+ and other MCP-compatible agents discover deadpush automatically.
328
+ """
329
+ deadpush_cmd = str(Path(sys.executable).parent / "deadpush")
330
+ if not Path(deadpush_cmd).exists():
331
+ deadpush_cmd = sys.executable.replace("python3", "deadpush").replace("python", "deadpush")
332
+ if not Path(deadpush_cmd).exists():
333
+ deadpush_cmd = "deadpush"
334
+
335
+ mcp_config = {
336
+ "mcpServers": {
337
+ "deadpush": {
338
+ "command": deadpush_cmd,
339
+ "args": ["mcp"],
340
+ }
341
+ }
342
+ }
343
+
344
+ cursor_dir = repo_root / ".cursor"
345
+ cursor_dir.mkdir(parents=True, exist_ok=True)
346
+ cursor_path = cursor_dir / "mcp.json"
347
+ existing = {}
348
+ if cursor_path.exists():
349
+ try:
350
+ existing = json.loads(cursor_path.read_text(encoding="utf-8"))
351
+ except Exception:
352
+ pass
353
+ existing.update(mcp_config)
354
+ cursor_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
355
+ print(f" Created {cursor_path}")
356
+
357
+ vscode_dir = repo_root / ".vscode"
358
+ vscode_dir.mkdir(parents=True, exist_ok=True)
359
+ vscode_path = vscode_dir / "mcp.json"
360
+ vscode_config = {"servers": {"deadpush": {"command": deadpush_cmd, "args": ["mcp"]}}}
361
+ existing_vs = {}
362
+ if vscode_path.exists():
363
+ try:
364
+ existing_vs = json.loads(vscode_path.read_text(encoding="utf-8"))
365
+ except Exception:
366
+ pass
367
+ existing_vs.update(vscode_config)
368
+ vscode_path.write_text(json.dumps(existing_vs, indent=2), encoding="utf-8")
369
+ print(f" Created {vscode_path}")
@@ -0,0 +1,122 @@
1
+ """Cross-file import analysis for dead code detection.
2
+
3
+ Builds an import map across all Python source files and provides
4
+ efficient queries for import counts and string references.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class ImportAnalyzer:
17
+ """Analyze cross-file imports and string references for dead code scoring."""
18
+
19
+ def __init__(self, file_paths: list[Path], repo_root: Path):
20
+ self.repo_root = repo_root
21
+ self._imports: dict[str, list[str]] = defaultdict(
22
+ list
23
+ ) # imported_name -> [file_paths]
24
+ self._string_refs: dict[str, dict[str, list[int]]] = defaultdict(
25
+ lambda: defaultdict(list)
26
+ ) # name -> file_path -> [line_numbers]
27
+ self._exclude_files: set[str] = set()
28
+ self._build(file_paths)
29
+
30
+ def _rel(self, path: Path | str) -> str:
31
+ p = Path(path) if isinstance(path, str) else path
32
+ try:
33
+ return str(p.relative_to(self.repo_root))
34
+ except ValueError:
35
+ return str(p)
36
+
37
+ def _build(self, file_paths: list[Path]) -> None:
38
+ for fp in file_paths:
39
+ if fp.suffix != ".py":
40
+ continue
41
+ rel = self._rel(fp)
42
+ try:
43
+ text = fp.read_text(encoding="utf-8", errors="ignore")
44
+ except Exception:
45
+ continue
46
+ self._extract_imports(text, rel)
47
+ self._extract_string_refs(text, rel, fp)
48
+
49
+ def _extract_imports(self, text: str, rel: str) -> None:
50
+ try:
51
+ tree = ast.parse(text)
52
+ except SyntaxError:
53
+ return
54
+
55
+ for node in ast.walk(tree):
56
+ if isinstance(node, ast.Import):
57
+ for alias in node.names:
58
+ name = alias.asname or alias.name
59
+ self._imports[name].append(rel)
60
+ elif isinstance(node, ast.ImportFrom):
61
+ for alias in node.names:
62
+ name = alias.asname or alias.name
63
+ self._imports[name].append(rel)
64
+
65
+ def _extract_string_refs(self, text: str, rel: str, fp: Path) -> None:
66
+ try:
67
+ tree = ast.parse(text)
68
+ except SyntaxError:
69
+ return
70
+
71
+ # Attach parent references for docstring detection
72
+ for node in ast.walk(tree):
73
+ for child in ast.iter_child_nodes(node):
74
+ child.parent = node # type: ignore[attr-defined]
75
+
76
+ for node in ast.walk(tree):
77
+ if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
78
+ continue
79
+ val = node.value.strip()
80
+ if not val.isidentifier() or len(val) <= 1:
81
+ continue
82
+ # Exclude docstrings
83
+ if isinstance(node.parent, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
84
+ if isinstance(node.parent, ast.Module):
85
+ if node.parent.body and node.parent.body[0] is node:
86
+ continue
87
+ elif node.parent.body and node.parent.body[0] is node:
88
+ continue
89
+ # Exclude logging/print calls (strings as arguments to call keywords)
90
+ if isinstance(node.parent, ast.Call):
91
+ if isinstance(node.parent.func, ast.Attribute):
92
+ if node.parent.func.attr.lower() in ("info", "debug", "warning", "error", "critical", "log", "exception"):
93
+ continue
94
+ if isinstance(node.parent.func, ast.Name) and node.parent.func.id.lower() == "print":
95
+ continue
96
+ # Exclude strings in attribute access (obj.name) — those are attribute lookups, not string refs
97
+ if isinstance(node.parent, ast.Attribute):
98
+ if node.parent.attr == node.value:
99
+ continue
100
+ self._string_refs[val][rel].append(node.lineno)
101
+
102
+ def count_external_imports(self, name: str, exclude_path: str) -> int:
103
+ """Count files (excluding the definition file) that import this name."""
104
+ exclude_rel = self._rel(exclude_path)
105
+ files = self._imports.get(name, [])
106
+ return len([f for f in files if f != exclude_rel])
107
+
108
+ def count_string_references(self, name: str, exclude_path: str) -> int:
109
+ """Count string literal occurrences of name outside its own file."""
110
+ exclude_rel = self._rel(exclude_path)
111
+ total = 0
112
+ for file_path, line_nums in self._string_refs.get(name, {}).items():
113
+ if file_path != exclude_rel:
114
+ total += len(line_nums)
115
+ return total
116
+
117
+ def get_importing_files(self, name: str) -> list[str]:
118
+ """Get list of files (relative paths) that import the given name."""
119
+ return list(self._imports.get(name, []))
120
+
121
+ def get_all_imported_names(self) -> set[str]:
122
+ return set(self._imports.keys())