amagi-hookguard 0.1.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.
@@ -0,0 +1,3 @@
1
+ """amagi-hookguard: tested guardrail hooks for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,142 @@
1
+ """Append-only JSONL audit log of tool calls, with secrets redacted."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Iterator, List, Optional
11
+
12
+ from .config import Config, claude_home
13
+
14
+ ENV_VAR = "AMAGI_HOOKGUARD_AUDIT"
15
+ MAX_FIELD = 400
16
+
17
+ _REDACTIONS = [
18
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(-----END [A-Z ]*PRIVATE KEY-----|$)", re.S), "[REDACTED PRIVATE KEY]"),
19
+ (re.compile(r"\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
20
+ (re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"), "[REDACTED]"),
21
+ (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
22
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED]"),
23
+ (re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED]"),
24
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{30,}"), "[REDACTED]"),
25
+ (re.compile(r"\bpypi-[A-Za-z0-9_-]{20,}"), "[REDACTED]"),
26
+ (re.compile(r"(?i)\b(bearer|token)\s+[A-Za-z0-9._~+/=-]{16,}"), r"\1 [REDACTED]"),
27
+ (
28
+ re.compile(r"(?i)\b([A-Za-z0-9_]*(password|passwd|secret|token|api[_-]?key)[A-Za-z0-9_]*)\s*([=:])\s*(\"[^\"]*\"|'[^']*'|[^\s\"']+)"),
29
+ r"\1\3[REDACTED]",
30
+ ),
31
+ (re.compile(r"(?i)(://[^:/\s@]+:)[^@\s/]+@"), r"\1[REDACTED]@"),
32
+ ]
33
+
34
+
35
+ def redact(text: str) -> str:
36
+ for pattern, repl in _REDACTIONS:
37
+ text = pattern.sub(repl, text)
38
+ return text
39
+
40
+
41
+ def _clip(text: str, limit: int = MAX_FIELD) -> str:
42
+ return text if len(text) <= limit else text[:limit] + f"... [+{len(text) - limit} chars]"
43
+
44
+
45
+ def audit_path(cfg: Optional[Config] = None) -> Path:
46
+ env = os.environ.get(ENV_VAR)
47
+ if env:
48
+ return Path(env).expanduser()
49
+ if cfg is not None and cfg.audit_log:
50
+ return Path(cfg.audit_log).expanduser()
51
+ return claude_home() / "hookguard" / "audit.jsonl"
52
+
53
+
54
+ def summarize_input(tool_name: str, tool_input: Any) -> str:
55
+ """One short, redacted line describing what the tool was asked to do."""
56
+ if not isinstance(tool_input, dict):
57
+ return ""
58
+ for key in ("command", "file_path", "notebook_path", "url", "pattern", "path", "query", "prompt", "description"):
59
+ value = tool_input.get(key)
60
+ if isinstance(value, str) and value:
61
+ extra = ""
62
+ if key == "pattern" and isinstance(tool_input.get("path"), str):
63
+ extra = f" in {tool_input['path']}"
64
+ return _clip(redact(value + extra))
65
+ try:
66
+ return _clip(redact(json.dumps(tool_input, sort_keys=True, default=str)))
67
+ except (TypeError, ValueError):
68
+ return ""
69
+
70
+
71
+ def response_ok(response: Any) -> Optional[bool]:
72
+ if isinstance(response, dict):
73
+ if response.get("interrupted") is True or response.get("is_error") is True:
74
+ return False
75
+ if response.get("success") is False:
76
+ return False
77
+ return True
78
+ return None
79
+
80
+
81
+ def response_size(response: Any) -> int:
82
+ if response is None:
83
+ return 0
84
+ if isinstance(response, str):
85
+ return len(response)
86
+ try:
87
+ return len(json.dumps(response, default=str))
88
+ except (TypeError, ValueError):
89
+ return 0
90
+
91
+
92
+ def now_iso() -> str:
93
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
94
+
95
+
96
+ def write(record: Dict[str, Any], cfg: Optional[Config] = None) -> Path:
97
+ path = audit_path(cfg)
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ max_bytes = cfg.audit_max_bytes if cfg is not None else 10_000_000
100
+ try:
101
+ if max_bytes and path.exists() and path.stat().st_size > max_bytes:
102
+ rotated = path.with_name(path.name + ".1")
103
+ if rotated.exists():
104
+ rotated.unlink()
105
+ path.rename(rotated)
106
+ except OSError:
107
+ pass
108
+ line = json.dumps(record, ensure_ascii=False, sort_keys=False)
109
+ with open(path, "a", encoding="utf-8") as fh:
110
+ fh.write(line + "\n")
111
+ return path
112
+
113
+
114
+ def read(path: Path) -> Iterator[Dict[str, Any]]:
115
+ if not path.exists():
116
+ return
117
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
118
+ for line in fh:
119
+ line = line.strip()
120
+ if not line:
121
+ continue
122
+ try:
123
+ rec = json.loads(line)
124
+ except ValueError:
125
+ continue
126
+ if isinstance(rec, dict):
127
+ yield rec
128
+
129
+
130
+ def format_record(rec: Dict[str, Any]) -> str:
131
+ event = {"PreToolUse": "PRE ", "PostToolUse": "POST"}.get(rec.get("event", ""), (rec.get("event") or "?")[:4])
132
+ if rec.get("event") == "PreToolUse":
133
+ status = (rec.get("decision") or "allow").upper()
134
+ else:
135
+ ok = rec.get("ok")
136
+ status = "ok" if ok is True else ("FAIL" if ok is False else "-")
137
+ rule = f" [{rec['rule']}]" if rec.get("rule") else ""
138
+ return f"{rec.get('ts', '?')} {event} {status:<5} {rec.get('tool', '?'):<10} {rec.get('input', '')}{rule}"
139
+
140
+
141
+ def tail(records: List[Dict[str, Any]], n: int) -> List[Dict[str, Any]]:
142
+ return records[-n:] if n > 0 else records
amagi_hookguard/cli.py ADDED
@@ -0,0 +1,222 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+
13
+ # Everything else is imported inside the handlers: `hook` runs on every tool
14
+ # call, so it should load as little as possible.
15
+
16
+ PROG = "amagi-hookguard"
17
+
18
+
19
+ def _parser() -> "argparse.ArgumentParser":
20
+ import argparse
21
+
22
+ p = argparse.ArgumentParser(
23
+ prog=PROG,
24
+ description="Guardrail hooks for Claude Code: block destructive commands, secret-file reads and "
25
+ "force pushes to main, log every tool call, and check your hook wiring.",
26
+ )
27
+ p.add_argument("--version", action="version", version=f"{PROG} {__version__}")
28
+ sub = p.add_subparsers(dest="cmd", metavar="COMMAND")
29
+
30
+ sub.add_parser("hook", help="run as a Claude Code hook (reads one event as JSON on stdin)")
31
+
32
+ pi = sub.add_parser("install", help="add hookguard to a settings.json (backs up the old file)")
33
+ pi.add_argument("--scope", choices=["user", "project", "local"], default="user",
34
+ help="user = ~/.claude/settings.json (default), project = .claude/settings.json, "
35
+ "local = .claude/settings.local.json")
36
+ pi.add_argument("--settings", metavar="PATH", help="write to this settings file instead of a scope")
37
+ pi.add_argument("--project", metavar="DIR", help="project folder for project/local scope (default: current folder)")
38
+ pi.add_argument("--absolute", action="store_true", help="write the full path to the executable instead of relying on PATH")
39
+ pi.add_argument("--dry-run", action="store_true", help="print the result without writing")
40
+
41
+ pu = sub.add_parser("uninstall", help="remove hookguard from a settings.json")
42
+ pu.add_argument("--scope", choices=["user", "project", "local"], default="user")
43
+ pu.add_argument("--settings", metavar="PATH")
44
+ pu.add_argument("--project", metavar="DIR")
45
+ pu.add_argument("--dry-run", action="store_true")
46
+
47
+ pd = sub.add_parser("doctor", help="check hook wiring in your Claude Code settings files")
48
+ pd.add_argument("--settings", metavar="PATH", action="append", help="check only this file (repeatable)")
49
+ pd.add_argument("--project", metavar="DIR", help="project folder (default: current folder)")
50
+ pd.add_argument("--no-exec", action="store_true", help="don't run the wired hook command with sample events")
51
+ pd.add_argument("--json", action="store_true", help="machine-readable output")
52
+
53
+ pc = sub.add_parser("check", help="show what hookguard would do with a command or file read")
54
+ pc.add_argument("command", nargs="?", help="shell command to test (as the Bash tool would run it)")
55
+ pc.add_argument("--read", metavar="PATH", help="test a Read of this file instead")
56
+ pc.add_argument("--tool", metavar="NAME", help="tool name for --input (e.g. Grep, PowerShell)")
57
+ pc.add_argument("--input", metavar="JSON", help="raw tool_input JSON for --tool")
58
+ pc.add_argument("--cwd", metavar="DIR", help="working folder for git branch lookups (default: current folder)")
59
+
60
+ sub.add_parser("rules", help="list the rules and what each one does")
61
+
62
+ pl = sub.add_parser("log", help="show the audit log")
63
+ pl.add_argument("-n", "--tail", type=int, default=20, metavar="N", help="last N entries (0 = all, default 20)")
64
+ pl.add_argument("--blocked", action="store_true", help="only calls hookguard blocked or flagged")
65
+ pl.add_argument("--session", metavar="ID", help="only this session id")
66
+ pl.add_argument("--json", action="store_true", help="raw JSON lines")
67
+ pl.add_argument("--path", action="store_true", help="print the audit log path and exit")
68
+ return p
69
+
70
+
71
+ def _settings_target(args) -> Path:
72
+ from . import settings
73
+
74
+ if args.settings:
75
+ return Path(args.settings).expanduser()
76
+ return settings.scope_path(args.scope, args.project)
77
+
78
+
79
+ def cmd_install(args) -> int:
80
+ from . import doctor, settings
81
+
82
+ path = _settings_target(args)
83
+ command = settings.hook_command(args.absolute)
84
+ try:
85
+ text, backup = settings.install(path, command, dry_run=args.dry_run)
86
+ except (ValueError, json.JSONDecodeError) as exc:
87
+ print(f"{PROG}: {path} is not valid settings JSON, nothing changed: {exc}", file=sys.stderr)
88
+ return 1
89
+ shown = doctor.display_path(path)
90
+ if args.dry_run:
91
+ print(f"Would write {shown}:\n")
92
+ print(text)
93
+ return 0
94
+ print(f"Wired hookguard into {shown} (PreToolUse + PostToolUse, matcher \"*\", command `{command}`).")
95
+ if backup:
96
+ print(f"Previous file saved as {doctor.display_path(backup)}")
97
+ print("Restart Claude Code (or open /hooks) so it picks up the change, then run: amagi-hookguard doctor")
98
+ return 0
99
+
100
+
101
+ def cmd_uninstall(args) -> int:
102
+ from . import doctor, settings
103
+
104
+ path = _settings_target(args)
105
+ try:
106
+ removed, backup = settings.uninstall(path, dry_run=args.dry_run)
107
+ except (ValueError, json.JSONDecodeError) as exc:
108
+ print(f"{PROG}: {path} is not valid settings JSON, nothing changed: {exc}", file=sys.stderr)
109
+ return 1
110
+ shown = doctor.display_path(path)
111
+ if not removed:
112
+ print(f"No hookguard hooks found in {shown}.")
113
+ return 0
114
+ verb = "Would remove" if args.dry_run else "Removed"
115
+ print(f"{verb} {removed} hookguard hook(s) from {shown}.")
116
+ if backup:
117
+ print(f"Previous file saved as {doctor.display_path(backup)}")
118
+ return 0
119
+
120
+
121
+ def cmd_doctor(args) -> int:
122
+ from . import doctor
123
+
124
+ sections = doctor.run_doctor(args.project, args.settings, execute=not args.no_exec)
125
+ print(doctor.to_json(sections) if args.json else doctor.render(sections))
126
+ return 1 if doctor.counts(sections)[doctor.FAIL] else 0
127
+
128
+
129
+ def cmd_check(args) -> int:
130
+ from .config import load_config
131
+ from .rules import ASK, BLOCK, evaluate
132
+
133
+ cwd = os.path.abspath(args.cwd or os.getcwd())
134
+ if args.read:
135
+ tool, tool_input = "Read", {"file_path": args.read}
136
+ elif args.tool:
137
+ tool = args.tool
138
+ try:
139
+ tool_input = json.loads(args.input or "{}")
140
+ except ValueError as exc:
141
+ print(f"{PROG}: --input is not valid JSON: {exc}", file=sys.stderr)
142
+ return 1
143
+ if args.command and "command" not in tool_input:
144
+ tool_input["command"] = args.command
145
+ elif args.command:
146
+ tool, tool_input = "Bash", {"command": args.command}
147
+ else:
148
+ print(f"{PROG} check: give a command, --read PATH, or --tool NAME --input JSON", file=sys.stderr)
149
+ return 1
150
+ decision = evaluate(tool, tool_input, cwd, load_config(cwd))
151
+ label = {BLOCK: "BLOCK", ASK: "ASK"}.get(decision.action, "allow")
152
+ shown = tool_input.get("command") or tool_input.get("file_path") or json.dumps(tool_input)
153
+ line = f"{label:<5} {tool}: {shown}"
154
+ if decision.rule:
155
+ line += f"\n [{decision.rule}] {decision.reason}"
156
+ print(line)
157
+ return {BLOCK: 2, ASK: 3}.get(decision.action, 0)
158
+
159
+
160
+ def cmd_rules(args) -> int:
161
+ from .rules import RULES
162
+
163
+ for r in RULES:
164
+ print(f"{r.action.upper():<5} {r.id:<26} {r.summary}")
165
+ print("\nTurn a rule off with \"disabled_rules\": [\"<id>\"] in ~/.claude/hookguard.json.")
166
+ return 0
167
+
168
+
169
+ def cmd_log(args) -> int:
170
+ from . import audit, doctor
171
+ from .config import load_config
172
+ from .rules import ASK, BLOCK
173
+
174
+ path = audit.audit_path(load_config(os.getcwd()))
175
+ if args.path:
176
+ print(path)
177
+ return 0
178
+ records = list(audit.read(path))
179
+ if args.blocked:
180
+ records = [r for r in records if r.get("decision") in (BLOCK, ASK)]
181
+ if args.session:
182
+ records = [r for r in records if r.get("session") == args.session]
183
+ records = audit.tail(records, args.tail)
184
+ if not records:
185
+ print(f"No entries in {doctor.display_path(path)}.")
186
+ return 0
187
+ for rec in records:
188
+ print(json.dumps(rec, ensure_ascii=False) if args.json else audit.format_record(rec))
189
+ return 0
190
+
191
+
192
+ def main(argv: Optional[List[str]] = None) -> int:
193
+ for stream in (sys.stdout, sys.stderr):
194
+ try:
195
+ stream.reconfigure(encoding="utf-8", errors="replace")
196
+ except (AttributeError, ValueError):
197
+ pass
198
+ if argv is None:
199
+ argv = sys.argv[1:]
200
+ # Fast path for the hook: on Python 3.13+ argparse alone pulls in ~0.3 s of
201
+ # imports (_colorize, dataclasses, inspect, shutil), paid on every tool call.
202
+ if list(argv) == ["hook"]:
203
+ from . import hook
204
+
205
+ return hook.run()
206
+ args = _parser().parse_args(argv)
207
+ if args.cmd is None:
208
+ _parser().print_help()
209
+ return 0
210
+ if args.cmd == "hook":
211
+ from . import hook
212
+
213
+ return hook.run()
214
+ handlers = {
215
+ "install": cmd_install,
216
+ "uninstall": cmd_uninstall,
217
+ "doctor": cmd_doctor,
218
+ "check": cmd_check,
219
+ "rules": cmd_rules,
220
+ "log": cmd_log,
221
+ }
222
+ return handlers[args.cmd](args)
@@ -0,0 +1,132 @@
1
+ """Load hookguard.json from the user and project .claude folders.
2
+
3
+ The user file can do anything. A project file (checked into a repo you may
4
+ not control) can only add protection unless the user file sets
5
+ "trust_project_config": true.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ CONFIG_NAME = "hookguard.json"
16
+ DEFAULT_PROTECTED = ["main", "master"]
17
+
18
+ KNOWN_KEYS = {
19
+ "disabled_rules",
20
+ "protected_branches",
21
+ "secret_globs",
22
+ "secret_allow_globs",
23
+ "allow_commands",
24
+ "ask_as_block",
25
+ "fail_closed",
26
+ "audit_log",
27
+ "audit_max_bytes",
28
+ "trust_project_config",
29
+ }
30
+ # Keys an untrusted project file may set: they only make the guard stricter.
31
+ STRICTER_KEYS = {"protected_branches", "secret_globs", "ask_as_block", "fail_closed"}
32
+
33
+
34
+ class Config:
35
+ # A plain class rather than a dataclass: importing dataclasses pulls in
36
+ # inspect and ast, which roughly doubles the hook's start-up time.
37
+ def __init__(self) -> None:
38
+ self.disabled_rules: List[str] = []
39
+ self.protected_branches: List[str] = list(DEFAULT_PROTECTED)
40
+ self.secret_globs: List[str] = []
41
+ self.secret_allow_globs: List[str] = []
42
+ self.allow_commands: List[str] = []
43
+ self.ask_as_block = False
44
+ self.fail_closed = False
45
+ self.audit_log: Optional[str] = None
46
+ self.audit_max_bytes = 10_000_000
47
+ self.sources: List[str] = []
48
+ self.notes: List[str] = []
49
+
50
+ def __repr__(self) -> str:
51
+ return f"Config({self.__dict__!r})"
52
+
53
+
54
+ def claude_home() -> Path:
55
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
56
+ return Path(env).expanduser() if env else Path.home() / ".claude"
57
+
58
+
59
+ def user_config_path() -> Path:
60
+ return claude_home() / CONFIG_NAME
61
+
62
+
63
+ def project_config_path(project_dir: Optional[str]) -> Optional[Path]:
64
+ if not project_dir:
65
+ return None
66
+ return Path(project_dir) / ".claude" / CONFIG_NAME
67
+
68
+
69
+ def read_json(path: Path) -> Dict:
70
+ with open(path, "r", encoding="utf-8-sig") as fh:
71
+ data = json.load(fh)
72
+ if not isinstance(data, dict):
73
+ raise ValueError("top level must be a JSON object")
74
+ return data
75
+
76
+
77
+ def _apply(cfg: Config, data: Dict, path: Path, trusted: bool) -> None:
78
+ for key, value in data.items():
79
+ if key not in KNOWN_KEYS:
80
+ cfg.notes.append(f"{path}: unknown key '{key}' ignored")
81
+ continue
82
+ if not trusted and key not in STRICTER_KEYS:
83
+ cfg.notes.append(
84
+ f"{path}: '{key}' ignored (project config can only add protection; "
85
+ "set \"trust_project_config\": true in your user hookguard.json to allow it)"
86
+ )
87
+ continue
88
+ if key in ("ask_as_block", "fail_closed"):
89
+ if not trusted:
90
+ if value is True:
91
+ setattr(cfg, key, True)
92
+ else:
93
+ setattr(cfg, key, bool(value))
94
+ elif key in ("audit_max_bytes",):
95
+ try:
96
+ cfg.audit_max_bytes = int(value)
97
+ except (TypeError, ValueError):
98
+ cfg.notes.append(f"{path}: audit_max_bytes must be a number")
99
+ elif key in ("audit_log",):
100
+ cfg.audit_log = str(value) if value else None
101
+ elif key == "trust_project_config":
102
+ pass
103
+ else:
104
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
105
+ cfg.notes.append(f"{path}: '{key}' must be a list of strings")
106
+ continue
107
+ current = getattr(cfg, key)
108
+ for v in value:
109
+ if v not in current:
110
+ current.append(v)
111
+
112
+
113
+ def load_config(project_dir: Optional[str] = None) -> Config:
114
+ cfg = Config()
115
+ trust_project = False
116
+ upath = user_config_path()
117
+ if upath.is_file():
118
+ try:
119
+ data = read_json(upath)
120
+ _apply(cfg, data, upath, trusted=True)
121
+ trust_project = data.get("trust_project_config") is True
122
+ cfg.sources.append(str(upath))
123
+ except (OSError, ValueError) as exc:
124
+ cfg.notes.append(f"{upath}: could not read ({exc})")
125
+ ppath = project_config_path(project_dir)
126
+ if ppath is not None and ppath.is_file() and ppath.resolve() != upath.resolve():
127
+ try:
128
+ _apply(cfg, read_json(ppath), ppath, trusted=trust_project)
129
+ cfg.sources.append(str(ppath))
130
+ except (OSError, ValueError) as exc:
131
+ cfg.notes.append(f"{ppath}: could not read ({exc})")
132
+ return cfg