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.
- amagi_hookguard/__init__.py +3 -0
- amagi_hookguard/__main__.py +5 -0
- amagi_hookguard/audit.py +142 -0
- amagi_hookguard/cli.py +222 -0
- amagi_hookguard/config.py +132 -0
- amagi_hookguard/doctor.py +396 -0
- amagi_hookguard/gitcmd.py +144 -0
- amagi_hookguard/hook.py +114 -0
- amagi_hookguard/rules.py +419 -0
- amagi_hookguard/secretfiles.py +98 -0
- amagi_hookguard/settings.py +142 -0
- amagi_hookguard/shell.py +213 -0
- amagi_hookguard-0.1.0.dist-info/METADATA +240 -0
- amagi_hookguard-0.1.0.dist-info/RECORD +18 -0
- amagi_hookguard-0.1.0.dist-info/WHEEL +5 -0
- amagi_hookguard-0.1.0.dist-info/entry_points.txt +2 -0
- amagi_hookguard-0.1.0.dist-info/licenses/LICENSE +21 -0
- amagi_hookguard-0.1.0.dist-info/top_level.txt +1 -0
amagi_hookguard/audit.py
ADDED
|
@@ -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
|