bashgate 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.
bashgate.py
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code PreToolUse hook for screening bash commands.
|
|
4
|
+
|
|
5
|
+
Replaces Bash(...) permission rules in settings.json with a hook that also
|
|
6
|
+
validates paths, since permissionDecision "allow" bypasses Claude Code's
|
|
7
|
+
own path-outside-cwd check.
|
|
8
|
+
|
|
9
|
+
Commands are parsed with shlex to split compound commands at operator
|
|
10
|
+
boundaries. Each sub-command is checked against a configurable allowlist
|
|
11
|
+
loaded from ~/.claude/bashgate.json (or a path specified via
|
|
12
|
+
--config). Compound commands are allowed only if every sub-command is allowed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
# ── Operator constants ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
COMMAND_SEPARATORS = frozenset({"&&", "||", ";", "|", "|&", "\n"})
|
|
26
|
+
DANGEROUS_PUNCTUATION = frozenset({"(", ")", ";;", ";&", ";;&", "<<", "<<<"})
|
|
27
|
+
|
|
28
|
+
# ── Safe device paths ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
SAFE_DEV_PATHS = frozenset({"/dev/null", "/dev/stderr", "/dev/stdout", "/dev/stdin"})
|
|
31
|
+
|
|
32
|
+
_debug = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_sandbox(cwd):
|
|
36
|
+
"""Detect if Claude Code's sandbox is enabled for the given project.
|
|
37
|
+
|
|
38
|
+
Reads the project-local .claude/settings.local.json to check
|
|
39
|
+
sandbox.enabled. Returns True if sandboxed, False otherwise.
|
|
40
|
+
"""
|
|
41
|
+
settings_path = os.path.join(cwd, ".claude", "settings.local.json")
|
|
42
|
+
try:
|
|
43
|
+
with open(settings_path) as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
return bool(data.get("sandbox", {}).get("enabled", False))
|
|
46
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_safe_dev_path(path):
|
|
51
|
+
"""Check if path is a safe device path (including /dev/fd/ on macOS)."""
|
|
52
|
+
return path in SAFE_DEV_PATHS or path.startswith("/dev/fd/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Redirect operators ─────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
REDIRECT_OPERATORS = frozenset({">", ">>", "&>", "&>>"})
|
|
58
|
+
|
|
59
|
+
# ── Config validation ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate_string_list(value, path, errors):
|
|
63
|
+
"""Validate that value is a list of non-empty strings."""
|
|
64
|
+
if not isinstance(value, list):
|
|
65
|
+
errors.append(f"{path}: expected array, got {type(value).__name__}")
|
|
66
|
+
return
|
|
67
|
+
for i, item in enumerate(value):
|
|
68
|
+
if not isinstance(item, str):
|
|
69
|
+
errors.append(f"{path}[{i}]: expected string, got {type(item).__name__}")
|
|
70
|
+
elif not item:
|
|
71
|
+
errors.append(f"{path}[{i}]: empty string")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_unknown_keys(obj, known_keys, path, errors):
|
|
75
|
+
"""Report any keys in obj not in known_keys."""
|
|
76
|
+
for key in obj:
|
|
77
|
+
if key not in known_keys:
|
|
78
|
+
errors.append(f"{path}: unknown key {key!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_rule(rule, path, errors):
|
|
82
|
+
"""Validate an ask or deny config object."""
|
|
83
|
+
if not isinstance(rule, dict):
|
|
84
|
+
errors.append(f"{path}: expected object, got {type(rule).__name__}")
|
|
85
|
+
return
|
|
86
|
+
_check_unknown_keys(rule, {"flags", "arg_regex", "message"}, path, errors)
|
|
87
|
+
if "message" in rule:
|
|
88
|
+
if not isinstance(rule["message"], str):
|
|
89
|
+
errors.append(
|
|
90
|
+
f"{path}.message: expected string, got {type(rule['message']).__name__}"
|
|
91
|
+
)
|
|
92
|
+
elif not rule["message"]:
|
|
93
|
+
errors.append(f"{path}.message: empty string")
|
|
94
|
+
if "flags" in rule:
|
|
95
|
+
_validate_string_list(rule["flags"], f"{path}.flags", errors)
|
|
96
|
+
if "arg_regex" in rule:
|
|
97
|
+
raw = rule["arg_regex"]
|
|
98
|
+
if not isinstance(raw, str):
|
|
99
|
+
errors.append(
|
|
100
|
+
f"{path}.arg_regex: expected string, got {type(raw).__name__}"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
# Compile with the same boundary wrapping used at runtime
|
|
104
|
+
m = re.match(r"^(\(\?[aiLmsux]+\))(.*)", raw, re.DOTALL)
|
|
105
|
+
if m:
|
|
106
|
+
flags_prefix, body = m.group(1), m.group(2)
|
|
107
|
+
else:
|
|
108
|
+
flags_prefix, body = "", raw
|
|
109
|
+
pattern_str = flags_prefix + r"(?:^|\s)" + body + r"(?:\s|$)"
|
|
110
|
+
try:
|
|
111
|
+
re.compile(pattern_str)
|
|
112
|
+
except re.error as e:
|
|
113
|
+
errors.append(f"{path}.arg_regex: invalid regex {raw!r}: {e}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_allow(allow, path, is_subcommand, errors):
|
|
117
|
+
"""Validate an allow config object."""
|
|
118
|
+
if not isinstance(allow, dict):
|
|
119
|
+
errors.append(f"{path}: expected object, got {type(allow).__name__}")
|
|
120
|
+
return
|
|
121
|
+
if is_subcommand:
|
|
122
|
+
known = {"any_path", "flags_with_any_path"}
|
|
123
|
+
if "subcommands" in allow:
|
|
124
|
+
errors.append(f"{path}.subcommands: nested subcommands are not supported")
|
|
125
|
+
else:
|
|
126
|
+
known = {"subcommands", "any_path", "flags_with_any_path"}
|
|
127
|
+
_check_unknown_keys(allow, known, path, errors)
|
|
128
|
+
if "any_path" in allow:
|
|
129
|
+
ap = allow["any_path"]
|
|
130
|
+
if isinstance(ap, bool):
|
|
131
|
+
pass
|
|
132
|
+
elif isinstance(ap, dict):
|
|
133
|
+
_check_unknown_keys(ap, {"position"}, f"{path}.any_path", errors)
|
|
134
|
+
if "position" not in ap:
|
|
135
|
+
errors.append(f"{path}.any_path: object must have 'position' key")
|
|
136
|
+
elif not isinstance(ap["position"], int) or ap["position"] < 1:
|
|
137
|
+
errors.append(f"{path}.any_path.position: expected positive integer")
|
|
138
|
+
else:
|
|
139
|
+
errors.append(
|
|
140
|
+
f"{path}.any_path: expected boolean or object, got {type(ap).__name__}"
|
|
141
|
+
)
|
|
142
|
+
if "flags_with_any_path" in allow:
|
|
143
|
+
_validate_string_list(
|
|
144
|
+
allow["flags_with_any_path"], f"{path}.flags_with_any_path", errors
|
|
145
|
+
)
|
|
146
|
+
if "subcommands" in allow and not is_subcommand:
|
|
147
|
+
subs = allow["subcommands"]
|
|
148
|
+
if not isinstance(subs, list):
|
|
149
|
+
errors.append(
|
|
150
|
+
f"{path}.subcommands: expected array, got {type(subs).__name__}"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
for i, entry in enumerate(subs):
|
|
154
|
+
_validate_subcommand_entry(entry, f"{path}.subcommands[{i}]", errors)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_subcommand_entry(entry, path, errors):
|
|
158
|
+
"""Validate a subcommand entry (string or object)."""
|
|
159
|
+
if isinstance(entry, str):
|
|
160
|
+
if not entry:
|
|
161
|
+
errors.append(f"{path}: empty string")
|
|
162
|
+
return
|
|
163
|
+
if not isinstance(entry, dict):
|
|
164
|
+
errors.append(f"{path}: expected string or object, got {type(entry).__name__}")
|
|
165
|
+
return
|
|
166
|
+
_check_unknown_keys(entry, {"subcommand", "allow", "ask", "deny"}, path, errors)
|
|
167
|
+
if "command" in entry and "subcommand" not in entry:
|
|
168
|
+
errors.append(f"{path}: has 'command' key — did you mean 'subcommand'?")
|
|
169
|
+
return
|
|
170
|
+
if "subcommand" not in entry:
|
|
171
|
+
errors.append(f"{path}: missing required key 'subcommand'")
|
|
172
|
+
return
|
|
173
|
+
if not isinstance(entry["subcommand"], str):
|
|
174
|
+
errors.append(
|
|
175
|
+
f"{path}.subcommand: expected string, got {type(entry['subcommand']).__name__}"
|
|
176
|
+
)
|
|
177
|
+
elif not entry["subcommand"]:
|
|
178
|
+
errors.append(f"{path}.subcommand: empty string")
|
|
179
|
+
if "allow" in entry:
|
|
180
|
+
_validate_allow(
|
|
181
|
+
entry["allow"], f"{path}.allow", is_subcommand=True, errors=errors
|
|
182
|
+
)
|
|
183
|
+
if "ask" in entry:
|
|
184
|
+
_validate_rule(entry["ask"], f"{path}.ask", errors)
|
|
185
|
+
if "deny" in entry:
|
|
186
|
+
_validate_rule(entry["deny"], f"{path}.deny", errors)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _validate_command_entry(entry, path, errors):
|
|
190
|
+
"""Validate a single command entry (string or object)."""
|
|
191
|
+
if isinstance(entry, str):
|
|
192
|
+
if not entry:
|
|
193
|
+
errors.append(f"{path}: empty string")
|
|
194
|
+
return
|
|
195
|
+
if not isinstance(entry, dict):
|
|
196
|
+
errors.append(f"{path}: expected string or object, got {type(entry).__name__}")
|
|
197
|
+
return
|
|
198
|
+
_check_unknown_keys(
|
|
199
|
+
entry, {"command", "flags_with_args", "allow", "ask", "deny"}, path, errors
|
|
200
|
+
)
|
|
201
|
+
if "command" not in entry:
|
|
202
|
+
errors.append(f"{path}: missing required key 'command'")
|
|
203
|
+
return
|
|
204
|
+
if not isinstance(entry["command"], str):
|
|
205
|
+
errors.append(
|
|
206
|
+
f"{path}.command: expected string, got {type(entry['command']).__name__}"
|
|
207
|
+
)
|
|
208
|
+
elif not entry["command"]:
|
|
209
|
+
errors.append(f"{path}.command: empty string")
|
|
210
|
+
if "flags_with_args" in entry:
|
|
211
|
+
_validate_string_list(
|
|
212
|
+
entry["flags_with_args"], f"{path}.flags_with_args", errors
|
|
213
|
+
)
|
|
214
|
+
if "allow" in entry:
|
|
215
|
+
_validate_allow(
|
|
216
|
+
entry["allow"], f"{path}.allow", is_subcommand=False, errors=errors
|
|
217
|
+
)
|
|
218
|
+
if "ask" in entry:
|
|
219
|
+
_validate_rule(entry["ask"], f"{path}.ask", errors)
|
|
220
|
+
if "deny" in entry:
|
|
221
|
+
_validate_rule(entry["deny"], f"{path}.deny", errors)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def validate_config(data):
|
|
225
|
+
"""Validate a parsed config dict. Returns a list of error strings (empty = valid)."""
|
|
226
|
+
errors = []
|
|
227
|
+
if not isinstance(data, dict):
|
|
228
|
+
errors.append(f"config: expected object, got {type(data).__name__}")
|
|
229
|
+
return errors
|
|
230
|
+
_check_unknown_keys(
|
|
231
|
+
data,
|
|
232
|
+
{"commands", "allowed_directories", "disable_inside_sandbox", "enabled", "ignore_local_configs"},
|
|
233
|
+
"config",
|
|
234
|
+
errors,
|
|
235
|
+
)
|
|
236
|
+
for bool_key in ("disable_inside_sandbox", "enabled", "ignore_local_configs"):
|
|
237
|
+
if bool_key in data and not isinstance(data[bool_key], bool):
|
|
238
|
+
errors.append(
|
|
239
|
+
f"config.{bool_key}: expected boolean, got {type(data[bool_key]).__name__}"
|
|
240
|
+
)
|
|
241
|
+
commands = data.get("commands", [])
|
|
242
|
+
if not isinstance(commands, list):
|
|
243
|
+
errors.append(f"config.commands: expected array, got {type(commands).__name__}")
|
|
244
|
+
return errors
|
|
245
|
+
for i, entry in enumerate(commands):
|
|
246
|
+
_validate_command_entry(entry, f"commands[{i}]", errors)
|
|
247
|
+
if "allowed_directories" in data:
|
|
248
|
+
_validate_string_list(
|
|
249
|
+
data["allowed_directories"], "config.allowed_directories", errors
|
|
250
|
+
)
|
|
251
|
+
return errors
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── Parsed config container ────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ParsedConfig(NamedTuple):
|
|
258
|
+
prefix_entries: list
|
|
259
|
+
structured_entries: dict
|
|
260
|
+
allowed_directories: list
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── Config loading and parsing ─────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_config(path):
|
|
267
|
+
"""Read JSON config. Missing file → empty result. Invalid JSON → stderr + exit 1.
|
|
268
|
+
|
|
269
|
+
Returns (commands, allowed_directories, options) tuple where options is a dict
|
|
270
|
+
with keys: disable_inside_sandbox (bool), enabled (bool).
|
|
271
|
+
"""
|
|
272
|
+
default_options = {"disable_inside_sandbox": False, "enabled": True, "ignore_local_configs": False}
|
|
273
|
+
try:
|
|
274
|
+
with open(path) as f:
|
|
275
|
+
data = json.load(f)
|
|
276
|
+
except FileNotFoundError:
|
|
277
|
+
return ([], [], default_options)
|
|
278
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
279
|
+
fail(f"Error reading config {path}: {e}")
|
|
280
|
+
errors = validate_config(data)
|
|
281
|
+
if errors:
|
|
282
|
+
for err in errors:
|
|
283
|
+
print(f"Config error in {path}: {err}", file=sys.stderr)
|
|
284
|
+
fail(f"Config error in {path}: {errors[0]}")
|
|
285
|
+
config_dir = os.path.dirname(os.path.realpath(path))
|
|
286
|
+
allowed_dirs = data.get("allowed_directories", [])
|
|
287
|
+
resolved_dirs = []
|
|
288
|
+
for d in allowed_dirs:
|
|
289
|
+
if d.startswith("."):
|
|
290
|
+
resolved_dirs.append(os.path.realpath(os.path.join(config_dir, d)))
|
|
291
|
+
else:
|
|
292
|
+
resolved_dirs.append(d)
|
|
293
|
+
options = {
|
|
294
|
+
"disable_inside_sandbox": data.get("disable_inside_sandbox", False),
|
|
295
|
+
"enabled": data.get("enabled", True),
|
|
296
|
+
"ignore_local_configs": data.get("ignore_local_configs", False),
|
|
297
|
+
}
|
|
298
|
+
return (data.get("commands", []), resolved_dirs, options)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def find_local_configs(cwd):
|
|
302
|
+
"""Walk from cwd up to filesystem root, collecting .bashgate.json paths.
|
|
303
|
+
|
|
304
|
+
Returns list ordered furthest-ancestor-first (so highest precedence is last).
|
|
305
|
+
"""
|
|
306
|
+
paths = []
|
|
307
|
+
current = os.path.realpath(cwd)
|
|
308
|
+
while True:
|
|
309
|
+
candidate = os.path.join(current, ".bashgate.json")
|
|
310
|
+
if os.path.isfile(candidate):
|
|
311
|
+
paths.append(candidate)
|
|
312
|
+
parent = os.path.dirname(current)
|
|
313
|
+
if parent == current:
|
|
314
|
+
break
|
|
315
|
+
current = parent
|
|
316
|
+
paths.reverse()
|
|
317
|
+
return paths
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def merge_commands(*commands_lists):
|
|
321
|
+
"""Merge multiple command lists by identity, last-wins.
|
|
322
|
+
|
|
323
|
+
String entries are keyed by the full string.
|
|
324
|
+
Object entries are keyed by the 'command' field.
|
|
325
|
+
Higher-precedence lists should come later in the argument list.
|
|
326
|
+
Returns the merged list preserving insertion order.
|
|
327
|
+
"""
|
|
328
|
+
merged = {}
|
|
329
|
+
for commands in commands_lists:
|
|
330
|
+
for entry in commands:
|
|
331
|
+
if isinstance(entry, str):
|
|
332
|
+
key = entry
|
|
333
|
+
else:
|
|
334
|
+
key = entry["command"]
|
|
335
|
+
merged[key] = entry
|
|
336
|
+
return list(merged.values())
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def merge_allowed_directories(*dirs_lists):
|
|
340
|
+
"""Merge multiple allowed_directories lists, deduplicating while preserving order."""
|
|
341
|
+
seen = {}
|
|
342
|
+
for dirs in dirs_lists:
|
|
343
|
+
for d in dirs:
|
|
344
|
+
if d not in seen:
|
|
345
|
+
seen[d] = None
|
|
346
|
+
return list(seen)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _compile_rule(rule_dict):
|
|
350
|
+
"""Convert an ask/deny config dict into internal form with compiled regex.
|
|
351
|
+
|
|
352
|
+
An empty dict is valid (unconditional rule with default message).
|
|
353
|
+
Returns None only when rule_dict is None/falsy (i.e. the key was absent).
|
|
354
|
+
"""
|
|
355
|
+
if rule_dict is None:
|
|
356
|
+
return None
|
|
357
|
+
result = {}
|
|
358
|
+
if "flags" in rule_dict:
|
|
359
|
+
result["flags"] = frozenset(rule_dict["flags"])
|
|
360
|
+
if "arg_regex" in rule_dict:
|
|
361
|
+
raw = rule_dict["arg_regex"]
|
|
362
|
+
# Extract leading inline flags (e.g. (?i)) so they stay at the
|
|
363
|
+
# start of the pattern after we prepend boundary matchers.
|
|
364
|
+
m = re.match(r"^(\(\?[aiLmsux]+\))(.*)", raw, re.DOTALL)
|
|
365
|
+
if m:
|
|
366
|
+
flags_prefix, body = m.group(1), m.group(2)
|
|
367
|
+
else:
|
|
368
|
+
flags_prefix, body = "", raw
|
|
369
|
+
pattern_str = flags_prefix + r"(?:^|\s)" + body + r"(?:\s|$)"
|
|
370
|
+
result["arg_regex"] = re.compile(pattern_str)
|
|
371
|
+
if "message" in rule_dict:
|
|
372
|
+
result["message"] = rule_dict["message"]
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _parse_any_path(raw):
|
|
377
|
+
"""Parse any_path config value into internal form.
|
|
378
|
+
|
|
379
|
+
Returns True (exempt all), False (no exemption), or frozenset of positions.
|
|
380
|
+
"""
|
|
381
|
+
if isinstance(raw, bool):
|
|
382
|
+
return raw
|
|
383
|
+
if isinstance(raw, dict):
|
|
384
|
+
return frozenset({raw["position"]})
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _compile_rules(entry):
|
|
389
|
+
"""Compile ask and deny rules from an entry dict into a list of (compiled, decision).
|
|
390
|
+
|
|
391
|
+
Deny rules are checked first (listed before ask rules).
|
|
392
|
+
"""
|
|
393
|
+
rules = []
|
|
394
|
+
deny = _compile_rule(entry.get("deny"))
|
|
395
|
+
if deny is not None:
|
|
396
|
+
rules.append((deny, "deny"))
|
|
397
|
+
ask = _compile_rule(entry.get("ask"))
|
|
398
|
+
if ask is not None:
|
|
399
|
+
rules.append((ask, "ask"))
|
|
400
|
+
return rules or None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_subcommand_entry(entry):
|
|
404
|
+
"""Parse a subcommand entry (string or dict) into (prefix, config_or_None)."""
|
|
405
|
+
if isinstance(entry, str):
|
|
406
|
+
return (entry, None)
|
|
407
|
+
prefix = entry["subcommand"]
|
|
408
|
+
config = {
|
|
409
|
+
"rules": _compile_rules(entry),
|
|
410
|
+
"any_path": _parse_any_path(entry.get("allow", {}).get("any_path", False)),
|
|
411
|
+
"flags_with_any_path": frozenset(
|
|
412
|
+
entry.get("allow", {}).get("flags_with_any_path", [])
|
|
413
|
+
),
|
|
414
|
+
}
|
|
415
|
+
return (prefix, config)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def parse_config(commands):
|
|
419
|
+
"""Convert JSON config into internal lookup structures.
|
|
420
|
+
|
|
421
|
+
Returns (prefix_entries, structured_entries) where:
|
|
422
|
+
- prefix_entries: list of (prefix_string, deny_config) sorted longest-first
|
|
423
|
+
- structured_entries: dict of command_name → parsed entry dict
|
|
424
|
+
"""
|
|
425
|
+
prefix_entries = []
|
|
426
|
+
structured_entries = {}
|
|
427
|
+
|
|
428
|
+
for entry in commands:
|
|
429
|
+
if isinstance(entry, str):
|
|
430
|
+
prefix_entries.append((entry, None))
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
cmd = entry["command"]
|
|
434
|
+
allow = entry.get("allow", {})
|
|
435
|
+
has_subcommands = "subcommands" in allow
|
|
436
|
+
has_flags_with_args = "flags_with_args" in entry
|
|
437
|
+
has_any_path = allow.get("any_path", False) is not False
|
|
438
|
+
has_flags_with_any_path = bool(allow.get("flags_with_any_path"))
|
|
439
|
+
|
|
440
|
+
if (
|
|
441
|
+
has_subcommands
|
|
442
|
+
or has_flags_with_args
|
|
443
|
+
or has_any_path
|
|
444
|
+
or has_flags_with_any_path
|
|
445
|
+
):
|
|
446
|
+
parsed_subs = None
|
|
447
|
+
if has_subcommands:
|
|
448
|
+
raw_subs = entry["allow"]["subcommands"]
|
|
449
|
+
parsed_subs = [_parse_subcommand_entry(s) for s in raw_subs]
|
|
450
|
+
parsed_subs.sort(key=lambda x: len(x[0]), reverse=True)
|
|
451
|
+
|
|
452
|
+
structured_entries[cmd] = {
|
|
453
|
+
"flags_with_args": entry.get("flags_with_args", []),
|
|
454
|
+
"rules": _compile_rules(entry),
|
|
455
|
+
"any_path": _parse_any_path(allow.get("any_path", False)),
|
|
456
|
+
"flags_with_any_path": frozenset(allow.get("flags_with_any_path", [])),
|
|
457
|
+
"subcommands": parsed_subs,
|
|
458
|
+
}
|
|
459
|
+
else:
|
|
460
|
+
prefix_entries.append((cmd, _compile_rules(entry)))
|
|
461
|
+
|
|
462
|
+
prefix_entries.sort(key=lambda x: len(x[0]), reverse=True)
|
|
463
|
+
return prefix_entries, structured_entries
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ── Helper functions ─────────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def find_subcommand(tokens, flags_with_args):
|
|
470
|
+
"""Identify subcommand by finding the first non-flag token.
|
|
471
|
+
|
|
472
|
+
Skips tokens starting with '-'. For flags in flags_with_args, also
|
|
473
|
+
skips the next token (consumed as the flag's value). Handles
|
|
474
|
+
--flag=value and -Xvalue concatenated forms for flags_with_args.
|
|
475
|
+
|
|
476
|
+
Returns the remaining tokens starting from the first non-flag token.
|
|
477
|
+
"""
|
|
478
|
+
flags_set = set(flags_with_args) if flags_with_args else set()
|
|
479
|
+
i = 0
|
|
480
|
+
while i < len(tokens):
|
|
481
|
+
token = tokens[i]
|
|
482
|
+
if not token.startswith("-"):
|
|
483
|
+
return tokens[i:]
|
|
484
|
+
|
|
485
|
+
# Flag with separate value: -C dir
|
|
486
|
+
if token in flags_set:
|
|
487
|
+
i += 2
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# --flag=value form for flags_with_args
|
|
491
|
+
if "=" in token:
|
|
492
|
+
flag_part = token.split("=", 1)[0]
|
|
493
|
+
if flag_part in flags_set:
|
|
494
|
+
i += 1
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
# Short flag concatenated form: -Cvalue
|
|
498
|
+
for fwa in flags_with_args or []:
|
|
499
|
+
if (
|
|
500
|
+
len(fwa) == 2
|
|
501
|
+
and fwa.startswith("-")
|
|
502
|
+
and token.startswith(fwa)
|
|
503
|
+
and len(token) > len(fwa)
|
|
504
|
+
):
|
|
505
|
+
# Matched concatenated short flag (e.g. -Cvalue for -C);
|
|
506
|
+
# break inner loop and fall through to i += 1 below
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
i += 1
|
|
510
|
+
|
|
511
|
+
return []
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _check_single_rule(args, args_string, rule_config):
|
|
515
|
+
"""Check a single rule against arguments. Returns reason string or None."""
|
|
516
|
+
custom_message = rule_config.get("message")
|
|
517
|
+
|
|
518
|
+
# Unconditional rule (no flags or arg_regex configured)
|
|
519
|
+
if not rule_config.get("flags") and not rule_config.get("arg_regex"):
|
|
520
|
+
return custom_message or "Command blocked"
|
|
521
|
+
|
|
522
|
+
blocked_flags = rule_config.get("flags", frozenset())
|
|
523
|
+
if blocked_flags:
|
|
524
|
+
for arg in args:
|
|
525
|
+
if arg in blocked_flags:
|
|
526
|
+
return custom_message or f"{arg} requires approval"
|
|
527
|
+
if "=" in arg:
|
|
528
|
+
flag_part = arg.split("=", 1)[0]
|
|
529
|
+
if flag_part in blocked_flags:
|
|
530
|
+
return custom_message or f"{flag_part} requires approval"
|
|
531
|
+
|
|
532
|
+
arg_regex = rule_config.get("arg_regex")
|
|
533
|
+
if arg_regex:
|
|
534
|
+
m = arg_regex.search(args_string)
|
|
535
|
+
if m:
|
|
536
|
+
return custom_message or f"Blocked argument: {m.group().strip()}"
|
|
537
|
+
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def check_rules(args, args_string, rules):
|
|
542
|
+
"""Check ask/deny rules against arguments. Returns (reason, decision) or (None, None).
|
|
543
|
+
|
|
544
|
+
rules is a list of (compiled_rule, decision_string) pairs, checked in order.
|
|
545
|
+
"""
|
|
546
|
+
if not rules:
|
|
547
|
+
return (None, None)
|
|
548
|
+
|
|
549
|
+
for rule_config, decision in rules:
|
|
550
|
+
reason = _check_single_rule(args, args_string, rule_config)
|
|
551
|
+
if reason:
|
|
552
|
+
return (reason, decision)
|
|
553
|
+
|
|
554
|
+
return (None, None)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def find_path_outside_cwd(
|
|
558
|
+
args, cwd, exempt_flags=None, allowed_directories=None, non_path_positions=None
|
|
559
|
+
):
|
|
560
|
+
"""Return the first arg that resolves to a path outside cwd, or None.
|
|
561
|
+
|
|
562
|
+
Checks:
|
|
563
|
+
1. Non-flag tokens: check the whole token
|
|
564
|
+
2. --flag=value tokens: extract and check value
|
|
565
|
+
3. -Xvalue short flags: if value starts with /, ~, or .., check it
|
|
566
|
+
|
|
567
|
+
Flags in exempt_flags are excluded from checks 2 and 3.
|
|
568
|
+
Paths under allowed_directories are permitted even if outside cwd.
|
|
569
|
+
non_path_positions is an optional set of 1-indexed positional arg positions
|
|
570
|
+
to skip during path validation (e.g. frozenset({1}) skips the first
|
|
571
|
+
non-flag token).
|
|
572
|
+
"""
|
|
573
|
+
if exempt_flags is None:
|
|
574
|
+
exempt_flags = frozenset()
|
|
575
|
+
cwd = os.path.realpath(cwd)
|
|
576
|
+
resolved_allowed = []
|
|
577
|
+
for d in allowed_directories or []:
|
|
578
|
+
resolved_allowed.append(os.path.realpath(os.path.expanduser(d)))
|
|
579
|
+
|
|
580
|
+
def is_outside(path_str):
|
|
581
|
+
expanded = os.path.expanduser(path_str)
|
|
582
|
+
if os.path.isabs(expanded) or ".." in expanded.split(os.sep):
|
|
583
|
+
resolved = os.path.realpath(os.path.join(cwd, expanded))
|
|
584
|
+
if is_safe_dev_path(path_str) or is_safe_dev_path(resolved):
|
|
585
|
+
return False
|
|
586
|
+
if not resolved.startswith(cwd + os.sep) and resolved != cwd:
|
|
587
|
+
for allowed in resolved_allowed:
|
|
588
|
+
if resolved == allowed or resolved.startswith(allowed + os.sep):
|
|
589
|
+
return False
|
|
590
|
+
return True
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
pos_index = 0
|
|
594
|
+
for arg in args:
|
|
595
|
+
if not arg.startswith("-"):
|
|
596
|
+
# Rule 1: non-flag token
|
|
597
|
+
pos_index += 1
|
|
598
|
+
if non_path_positions and pos_index in non_path_positions:
|
|
599
|
+
continue
|
|
600
|
+
if is_outside(arg):
|
|
601
|
+
return arg
|
|
602
|
+
elif arg.startswith("--") and "=" in arg:
|
|
603
|
+
# Rule 2: --flag=value
|
|
604
|
+
flag_part, value = arg.split("=", 1)
|
|
605
|
+
if flag_part not in exempt_flags and is_outside(value):
|
|
606
|
+
return value
|
|
607
|
+
elif len(arg) > 2 and arg[0] == "-" and arg[1] != "-":
|
|
608
|
+
# Rule 3: -Xvalue short flag
|
|
609
|
+
flag_part = arg[:2]
|
|
610
|
+
value = arg[2:]
|
|
611
|
+
if (
|
|
612
|
+
flag_part not in exempt_flags
|
|
613
|
+
and (
|
|
614
|
+
value.startswith("/")
|
|
615
|
+
or value.startswith("~")
|
|
616
|
+
or value.startswith("..")
|
|
617
|
+
)
|
|
618
|
+
and is_outside(value)
|
|
619
|
+
):
|
|
620
|
+
return value
|
|
621
|
+
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def find_dangerous_redirect(parts):
|
|
626
|
+
"""Return a reason string if parts contain a redirect to an unsafe target."""
|
|
627
|
+
for i, part in enumerate(parts):
|
|
628
|
+
if part in REDIRECT_OPERATORS:
|
|
629
|
+
if i + 1 < len(parts):
|
|
630
|
+
target = parts[i + 1]
|
|
631
|
+
if not is_safe_dev_path(target):
|
|
632
|
+
return f"Output redirect to: {target}"
|
|
633
|
+
else:
|
|
634
|
+
return "Output redirect with no target"
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _debug_write(message):
|
|
639
|
+
"""Append a line to the debug log if debug mode is enabled."""
|
|
640
|
+
if _debug:
|
|
641
|
+
debug_log = os.path.expanduser("~/.claude/bashgate-debug.log")
|
|
642
|
+
with open(debug_log, "a") as f:
|
|
643
|
+
f.write(message + "\n===\n")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def respond(decision, reason):
|
|
647
|
+
_debug_write(f"DECISION: {decision} — {reason}")
|
|
648
|
+
|
|
649
|
+
json.dump(
|
|
650
|
+
{
|
|
651
|
+
"hookSpecificOutput": {
|
|
652
|
+
"hookEventName": "PreToolUse",
|
|
653
|
+
"permissionDecision": decision,
|
|
654
|
+
"permissionDecisionReason": reason,
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
sys.stdout,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def fail(message):
|
|
662
|
+
"""Deny the tool call, log to stderr, and exit."""
|
|
663
|
+
respond("deny", message)
|
|
664
|
+
print(message, file=sys.stderr)
|
|
665
|
+
sys.exit(1)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# ── Tokenization ────────────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def tokenize(command):
|
|
672
|
+
"""Tokenize a command string using shlex with punctuation_chars=True.
|
|
673
|
+
|
|
674
|
+
This splits on shell operators (&&, ||, ;, |, etc.) while respecting
|
|
675
|
+
quoting and escaping. Newlines are treated as command separators (like
|
|
676
|
+
bash) rather than whitespace. Returns a list of tokens.
|
|
677
|
+
Raises ValueError if the command cannot be parsed (e.g. unclosed quotes).
|
|
678
|
+
"""
|
|
679
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars="();<>|&\n")
|
|
680
|
+
lexer.whitespace_split = True
|
|
681
|
+
lexer.commenters = "#"
|
|
682
|
+
lexer.whitespace = " \t\r"
|
|
683
|
+
return list(lexer)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def find_backtick_outside_single_quotes(command):
|
|
687
|
+
"""Check for backticks outside single-quoted strings in the raw command.
|
|
688
|
+
|
|
689
|
+
Returns a reason string if a dangerous backtick is found, None otherwise.
|
|
690
|
+
Single-quoted backticks are safe (literal). Double-quoted and unquoted are dangerous.
|
|
691
|
+
"""
|
|
692
|
+
state = "unquoted" # "unquoted", "single", "double"
|
|
693
|
+
i = 0
|
|
694
|
+
while i < len(command):
|
|
695
|
+
ch = command[i]
|
|
696
|
+
if state == "unquoted":
|
|
697
|
+
if ch == "'":
|
|
698
|
+
state = "single"
|
|
699
|
+
elif ch == '"':
|
|
700
|
+
state = "double"
|
|
701
|
+
elif ch == "`":
|
|
702
|
+
return "Backtick substitution outside single quotes"
|
|
703
|
+
elif state == "single":
|
|
704
|
+
if ch == "'":
|
|
705
|
+
state = "unquoted"
|
|
706
|
+
# No escaping in single quotes — POSIX rule
|
|
707
|
+
elif state == "double":
|
|
708
|
+
if ch == "\\" and i + 1 < len(command):
|
|
709
|
+
i += 1 # skip escaped char
|
|
710
|
+
elif ch == '"':
|
|
711
|
+
state = "unquoted"
|
|
712
|
+
elif ch == "`":
|
|
713
|
+
return "Backtick substitution in double quotes"
|
|
714
|
+
i += 1
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def find_dangerous_token(tokens):
|
|
719
|
+
"""Scan tokens for dangerous shell constructs.
|
|
720
|
+
|
|
721
|
+
Returns a reason string if a dangerous construct is found, None if clean.
|
|
722
|
+
"""
|
|
723
|
+
for token in tokens:
|
|
724
|
+
if "$" in token and not token.endswith("$"):
|
|
725
|
+
return f"Variable/command expansion: {token}"
|
|
726
|
+
if token in (">(", "<("):
|
|
727
|
+
return f"Process substitution: {token}"
|
|
728
|
+
if token in DANGEROUS_PUNCTUATION:
|
|
729
|
+
return f"Shell construct: {token}"
|
|
730
|
+
if token == "&":
|
|
731
|
+
return "Background execution: &"
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def split_on_operators(tokens):
|
|
736
|
+
"""Split a token list at command separators.
|
|
737
|
+
|
|
738
|
+
Returns a list of sub-command token lists.
|
|
739
|
+
E.g. ['git', 'status', '&&', 'git', 'diff'] -> [['git', 'status'], ['git', 'diff']]
|
|
740
|
+
"""
|
|
741
|
+
commands = []
|
|
742
|
+
current = []
|
|
743
|
+
for token in tokens:
|
|
744
|
+
if token in COMMAND_SEPARATORS:
|
|
745
|
+
if current:
|
|
746
|
+
commands.append(current)
|
|
747
|
+
current = []
|
|
748
|
+
else:
|
|
749
|
+
current.append(token)
|
|
750
|
+
if current:
|
|
751
|
+
commands.append(current)
|
|
752
|
+
return commands
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# ── Command checker ──────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def check_command(parts, cwd, config: ParsedConfig):
|
|
759
|
+
"""Check a sub-command against config. Returns (decision, reason) or (None, None)."""
|
|
760
|
+
if not parts:
|
|
761
|
+
return (None, None)
|
|
762
|
+
|
|
763
|
+
# Check for dangerous redirects
|
|
764
|
+
redirect_issue = find_dangerous_redirect(parts)
|
|
765
|
+
if redirect_issue:
|
|
766
|
+
return ("ask", redirect_issue)
|
|
767
|
+
|
|
768
|
+
prefix_entries = config.prefix_entries
|
|
769
|
+
structured_entries = config.structured_entries
|
|
770
|
+
allowed_directories = config.allowed_directories
|
|
771
|
+
cmd = parts[0]
|
|
772
|
+
|
|
773
|
+
# Try structured entries first
|
|
774
|
+
if cmd in structured_entries:
|
|
775
|
+
entry = structured_entries[cmd]
|
|
776
|
+
rest = parts[1:]
|
|
777
|
+
subcmd_tokens = find_subcommand(rest, entry["flags_with_args"])
|
|
778
|
+
|
|
779
|
+
cmd_rules = entry["rules"]
|
|
780
|
+
cmd_any_path = entry["any_path"]
|
|
781
|
+
cmd_exempt_flags = entry["flags_with_any_path"]
|
|
782
|
+
subcommands = entry["subcommands"]
|
|
783
|
+
|
|
784
|
+
if subcommands is not None:
|
|
785
|
+
# Match subcommand against allowed list (sorted longest-first)
|
|
786
|
+
sub_str = " ".join(subcmd_tokens)
|
|
787
|
+
matched = None
|
|
788
|
+
|
|
789
|
+
for sub_prefix, sub_config in subcommands:
|
|
790
|
+
if sub_str == sub_prefix or sub_str.startswith(sub_prefix + " "):
|
|
791
|
+
matched = (sub_prefix, sub_config)
|
|
792
|
+
break
|
|
793
|
+
|
|
794
|
+
if matched is None:
|
|
795
|
+
if subcmd_tokens:
|
|
796
|
+
return ("ask", f"{cmd} {subcmd_tokens[0]} requires approval")
|
|
797
|
+
return ("ask", f"{cmd} requires approval")
|
|
798
|
+
|
|
799
|
+
sub_prefix, sub_config = matched
|
|
800
|
+
|
|
801
|
+
# Determine effective any_path and exempt_flags
|
|
802
|
+
any_path = cmd_any_path
|
|
803
|
+
exempt_flags = set(cmd_exempt_flags)
|
|
804
|
+
sub_rules = None
|
|
805
|
+
|
|
806
|
+
if sub_config:
|
|
807
|
+
any_path = any_path or sub_config["any_path"]
|
|
808
|
+
exempt_flags |= sub_config["flags_with_any_path"]
|
|
809
|
+
sub_rules = sub_config["rules"]
|
|
810
|
+
|
|
811
|
+
# Get sub-args (tokens after matched subcommand prefix)
|
|
812
|
+
prefix_word_count = len(sub_prefix.split())
|
|
813
|
+
sub_args = subcmd_tokens[prefix_word_count:]
|
|
814
|
+
args_str = " ".join(sub_args)
|
|
815
|
+
|
|
816
|
+
# Check command-level rules
|
|
817
|
+
if cmd_rules:
|
|
818
|
+
reason, decision = check_rules(sub_args, args_str, cmd_rules)
|
|
819
|
+
if reason:
|
|
820
|
+
return (decision, f"{cmd} {sub_prefix}: {reason}")
|
|
821
|
+
|
|
822
|
+
# Check subcommand-level rules
|
|
823
|
+
if sub_rules:
|
|
824
|
+
reason, decision = check_rules(sub_args, args_str, sub_rules)
|
|
825
|
+
if reason:
|
|
826
|
+
return (decision, f"{cmd} {sub_prefix}: {reason}")
|
|
827
|
+
|
|
828
|
+
# Path validation
|
|
829
|
+
if any_path is not True:
|
|
830
|
+
non_path_pos = any_path if isinstance(any_path, frozenset) else None
|
|
831
|
+
outside = find_path_outside_cwd(
|
|
832
|
+
rest,
|
|
833
|
+
cwd,
|
|
834
|
+
exempt_flags,
|
|
835
|
+
allowed_directories,
|
|
836
|
+
non_path_positions=non_path_pos,
|
|
837
|
+
)
|
|
838
|
+
if outside:
|
|
839
|
+
return ("ask", f"Path outside working directory: {outside}")
|
|
840
|
+
|
|
841
|
+
return ("allow", f"Allowed command: {cmd} {sub_prefix}")
|
|
842
|
+
|
|
843
|
+
else:
|
|
844
|
+
# Structured entry without subcommands (just flags_with_args and/or rules)
|
|
845
|
+
any_path = cmd_any_path
|
|
846
|
+
exempt_flags = cmd_exempt_flags
|
|
847
|
+
|
|
848
|
+
if cmd_rules:
|
|
849
|
+
args_str = " ".join(rest)
|
|
850
|
+
reason, decision = check_rules(rest, args_str, cmd_rules)
|
|
851
|
+
if reason:
|
|
852
|
+
return (decision, f"{cmd}: {reason}")
|
|
853
|
+
|
|
854
|
+
if any_path is not True:
|
|
855
|
+
non_path_pos = any_path if isinstance(any_path, frozenset) else None
|
|
856
|
+
outside = find_path_outside_cwd(
|
|
857
|
+
rest,
|
|
858
|
+
cwd,
|
|
859
|
+
exempt_flags,
|
|
860
|
+
allowed_directories,
|
|
861
|
+
non_path_positions=non_path_pos,
|
|
862
|
+
)
|
|
863
|
+
if outside:
|
|
864
|
+
return ("ask", f"Path outside working directory: {outside}")
|
|
865
|
+
|
|
866
|
+
return ("allow", f"Allowed command: {cmd}")
|
|
867
|
+
|
|
868
|
+
# Try prefix matching
|
|
869
|
+
full_cmd = " ".join(parts)
|
|
870
|
+
for prefix, rules in prefix_entries:
|
|
871
|
+
if full_cmd == prefix or full_cmd.startswith(prefix + " "):
|
|
872
|
+
if rules:
|
|
873
|
+
prefix_word_count = len(prefix.split())
|
|
874
|
+
rest = parts[prefix_word_count:]
|
|
875
|
+
args_str = " ".join(rest)
|
|
876
|
+
reason, decision = check_rules(rest, args_str, rules)
|
|
877
|
+
if reason:
|
|
878
|
+
return (decision, f"{parts[0]}: {reason}")
|
|
879
|
+
|
|
880
|
+
outside = find_path_outside_cwd(
|
|
881
|
+
parts, cwd, allowed_directories=allowed_directories
|
|
882
|
+
)
|
|
883
|
+
if outside:
|
|
884
|
+
return ("ask", f"Path outside working directory: {outside}")
|
|
885
|
+
|
|
886
|
+
return ("allow", f"Allowed command: {prefix}")
|
|
887
|
+
|
|
888
|
+
return (None, None)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ── Install command ───────────────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
def _settings_path():
|
|
894
|
+
return os.environ.get(
|
|
895
|
+
"BASHGATE_SETTINGS_PATH",
|
|
896
|
+
os.path.expanduser("~/.claude/settings.json"),
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def cmd_install():
|
|
901
|
+
"""Install bashgate as a PreToolUse hook in ~/.claude/settings.json."""
|
|
902
|
+
settings_path = _settings_path()
|
|
903
|
+
bashgate_path = os.path.realpath(sys.argv[0])
|
|
904
|
+
quoted_path = shlex.quote(bashgate_path)
|
|
905
|
+
hook_command = f"{quoted_path} hook"
|
|
906
|
+
|
|
907
|
+
# Load existing settings
|
|
908
|
+
try:
|
|
909
|
+
with open(settings_path) as f:
|
|
910
|
+
settings = json.load(f)
|
|
911
|
+
except FileNotFoundError:
|
|
912
|
+
settings = {}
|
|
913
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
914
|
+
print(f"Error reading {settings_path}: {e}", file=sys.stderr)
|
|
915
|
+
sys.exit(1)
|
|
916
|
+
|
|
917
|
+
hooks = settings.setdefault("hooks", {})
|
|
918
|
+
pre_tool_use = hooks.setdefault("PreToolUse", [])
|
|
919
|
+
|
|
920
|
+
# Find existing bashgate entry or create one
|
|
921
|
+
bashgate_entry = None
|
|
922
|
+
for entry in pre_tool_use:
|
|
923
|
+
if entry.get("matcher") != "Bash":
|
|
924
|
+
continue
|
|
925
|
+
for hook in entry.get("hooks", []):
|
|
926
|
+
if "bashgate" in hook.get("command", ""):
|
|
927
|
+
bashgate_entry = hook
|
|
928
|
+
break
|
|
929
|
+
if bashgate_entry:
|
|
930
|
+
break
|
|
931
|
+
|
|
932
|
+
if bashgate_entry:
|
|
933
|
+
old_command = bashgate_entry["command"]
|
|
934
|
+
bashgate_entry["command"] = hook_command
|
|
935
|
+
if old_command == hook_command:
|
|
936
|
+
print(f"Already installed in {settings_path}")
|
|
937
|
+
else:
|
|
938
|
+
_write_settings(settings_path, settings)
|
|
939
|
+
print(f"Updated hook command in {settings_path}")
|
|
940
|
+
print(f" was: {old_command}")
|
|
941
|
+
print(f" now: {hook_command}")
|
|
942
|
+
else:
|
|
943
|
+
# Create a new Bash matcher entry
|
|
944
|
+
new_entry = {
|
|
945
|
+
"matcher": "Bash",
|
|
946
|
+
"hooks": [{"type": "command", "command": hook_command}],
|
|
947
|
+
}
|
|
948
|
+
pre_tool_use.append(new_entry)
|
|
949
|
+
_write_settings(settings_path, settings)
|
|
950
|
+
print(f"Installed hook in {settings_path}")
|
|
951
|
+
print(f" command: {hook_command}")
|
|
952
|
+
|
|
953
|
+
# Copy default config if none exists
|
|
954
|
+
config_path = os.path.expanduser("~/.claude/bashgate.json")
|
|
955
|
+
if os.path.isfile(config_path):
|
|
956
|
+
print(f" config: {config_path}")
|
|
957
|
+
else:
|
|
958
|
+
default_config = os.path.join(os.path.dirname(os.path.realpath(__file__)), "bashgate.default.json")
|
|
959
|
+
if os.path.isfile(default_config):
|
|
960
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
961
|
+
shutil.copy2(default_config, config_path)
|
|
962
|
+
print(f" config: {config_path} (created from default)")
|
|
963
|
+
else:
|
|
964
|
+
print(f"\nNote: No config found at {config_path}")
|
|
965
|
+
print("Create one to define allowed commands. Without it, all commands fall through.")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _write_settings(path, settings):
|
|
969
|
+
"""Write settings JSON to the given path."""
|
|
970
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
971
|
+
with open(path, "w") as f:
|
|
972
|
+
json.dump(settings, f, indent=2)
|
|
973
|
+
f.write("\n")
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
# ── Main ─────────────────────────────────────────────────────────────────
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def cmd_help():
|
|
980
|
+
"""Display usage information."""
|
|
981
|
+
print("bashgate - Claude Code PreToolUse hook for screening bash commands")
|
|
982
|
+
print()
|
|
983
|
+
print("Commands:")
|
|
984
|
+
print(" bashgate hook [options] Run as a Claude Code PreToolUse hook (reads JSON from stdin)")
|
|
985
|
+
print(" bashgate install Install hook into ~/.claude/settings.json")
|
|
986
|
+
print(" bashgate validate Validate a config file")
|
|
987
|
+
print()
|
|
988
|
+
print("Hook options:")
|
|
989
|
+
print(" --config <path> Use only this config file (skip default discovery)")
|
|
990
|
+
print(" --debug Enable debug logging to ~/.claude/bashgate-debug.log")
|
|
991
|
+
print()
|
|
992
|
+
print("Validate options:")
|
|
993
|
+
print(" --config <path> Config file to validate (default: ~/.claude/bashgate.json)")
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def cmd_validate(args):
|
|
997
|
+
"""Validate a config file."""
|
|
998
|
+
config_path = None
|
|
999
|
+
i = 0
|
|
1000
|
+
while i < len(args):
|
|
1001
|
+
if args[i] == "--config" and i + 1 < len(args):
|
|
1002
|
+
config_path = args[i + 1]
|
|
1003
|
+
i += 2
|
|
1004
|
+
else:
|
|
1005
|
+
i += 1
|
|
1006
|
+
|
|
1007
|
+
config_path = config_path or os.path.expanduser("~/.claude/bashgate.json")
|
|
1008
|
+
try:
|
|
1009
|
+
with open(config_path) as f:
|
|
1010
|
+
data = json.load(f)
|
|
1011
|
+
except FileNotFoundError:
|
|
1012
|
+
print(f"Config file not found: {config_path}", file=sys.stderr)
|
|
1013
|
+
sys.exit(1)
|
|
1014
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
1015
|
+
print(f"Error reading config {config_path}: {e}", file=sys.stderr)
|
|
1016
|
+
sys.exit(1)
|
|
1017
|
+
errors = validate_config(data)
|
|
1018
|
+
if errors:
|
|
1019
|
+
for err in errors:
|
|
1020
|
+
print(f"{err}", file=sys.stderr)
|
|
1021
|
+
sys.exit(1)
|
|
1022
|
+
print(f"Config {config_path} is valid.")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def cmd_hook(args):
|
|
1026
|
+
"""Run as a Claude Code PreToolUse hook."""
|
|
1027
|
+
global_config_path = os.environ.get(
|
|
1028
|
+
"BASHGATE_GLOBAL_CONFIG",
|
|
1029
|
+
os.path.expanduser("~/.claude/bashgate.json"),
|
|
1030
|
+
)
|
|
1031
|
+
explicit_config = None
|
|
1032
|
+
debug = False
|
|
1033
|
+
|
|
1034
|
+
i = 0
|
|
1035
|
+
while i < len(args):
|
|
1036
|
+
if args[i] == "--config" and i + 1 < len(args):
|
|
1037
|
+
explicit_config = args[i + 1]
|
|
1038
|
+
i += 2
|
|
1039
|
+
elif args[i] == "--debug":
|
|
1040
|
+
debug = True
|
|
1041
|
+
i += 1
|
|
1042
|
+
else:
|
|
1043
|
+
i += 1
|
|
1044
|
+
|
|
1045
|
+
global _debug
|
|
1046
|
+
_debug = debug
|
|
1047
|
+
|
|
1048
|
+
# Read stdin early to get cwd for local config discovery
|
|
1049
|
+
try:
|
|
1050
|
+
data = json.load(sys.stdin)
|
|
1051
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
1052
|
+
fail(f"bashgate: invalid JSON on stdin: {e}")
|
|
1053
|
+
|
|
1054
|
+
if debug:
|
|
1055
|
+
debug_log = os.path.expanduser("~/.claude/bashgate-debug.log")
|
|
1056
|
+
with open(debug_log, "a") as f:
|
|
1057
|
+
f.write(json.dumps(data, indent=2) + "\n")
|
|
1058
|
+
f.write(
|
|
1059
|
+
f"sandbox_detected={detect_sandbox(data.get('cwd', os.getcwd()))}\n---\n"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Only process Bash tool invocations
|
|
1063
|
+
if data.get("tool_name") != "Bash":
|
|
1064
|
+
_debug_write(f"SKIPPED: not a Bash tool call (tool_name={data.get('tool_name')!r})")
|
|
1065
|
+
return
|
|
1066
|
+
|
|
1067
|
+
command = data.get("tool_input", {}).get("command", "").strip()
|
|
1068
|
+
cwd = data.get("cwd", os.getcwd())
|
|
1069
|
+
|
|
1070
|
+
# Load and merge configs
|
|
1071
|
+
if explicit_config is not None:
|
|
1072
|
+
commands, allowed_directories, options = load_config(explicit_config)
|
|
1073
|
+
else:
|
|
1074
|
+
global_commands, global_allowed_dirs, global_options = load_config(
|
|
1075
|
+
global_config_path
|
|
1076
|
+
)
|
|
1077
|
+
if global_options.get("ignore_local_configs", False):
|
|
1078
|
+
commands = global_commands
|
|
1079
|
+
allowed_directories = global_allowed_dirs
|
|
1080
|
+
options = global_options
|
|
1081
|
+
else:
|
|
1082
|
+
local_paths = find_local_configs(cwd)
|
|
1083
|
+
local_results = [load_config(p) for p in local_paths]
|
|
1084
|
+
local_commands_lists = [r[0] for r in local_results]
|
|
1085
|
+
local_allowed_dirs_lists = [r[1] for r in local_results]
|
|
1086
|
+
commands = merge_commands(global_commands, *local_commands_lists)
|
|
1087
|
+
allowed_directories = merge_allowed_directories(
|
|
1088
|
+
global_allowed_dirs, *local_allowed_dirs_lists
|
|
1089
|
+
)
|
|
1090
|
+
# Nearest-to-cwd local config wins for each option key, else global
|
|
1091
|
+
options = dict(global_options)
|
|
1092
|
+
for r in local_results:
|
|
1093
|
+
options.update(r[2])
|
|
1094
|
+
|
|
1095
|
+
# If disabled by config, fall through to default behavior
|
|
1096
|
+
if not options["enabled"]:
|
|
1097
|
+
_debug_write("SKIPPED: enabled=false in config, falling through")
|
|
1098
|
+
return
|
|
1099
|
+
|
|
1100
|
+
# If sandbox mode is active and config opts in, fall through to default behavior
|
|
1101
|
+
if options["disable_inside_sandbox"] and detect_sandbox(cwd):
|
|
1102
|
+
_debug_write("SKIPPED: sandbox mode active, falling through")
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
prefix_entries, structured_entries = parse_config(commands)
|
|
1106
|
+
config = ParsedConfig(prefix_entries, structured_entries, allowed_directories)
|
|
1107
|
+
|
|
1108
|
+
# Check for backticks outside single quotes on the raw command string
|
|
1109
|
+
# (must happen before shlex strips quotes)
|
|
1110
|
+
backtick_danger = find_backtick_outside_single_quotes(command)
|
|
1111
|
+
if backtick_danger:
|
|
1112
|
+
respond("ask", backtick_danger)
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
# Tokenize with shlex, respecting quotes and escaping
|
|
1116
|
+
try:
|
|
1117
|
+
tokens = tokenize(command)
|
|
1118
|
+
except ValueError:
|
|
1119
|
+
respond("ask", "Could not parse command")
|
|
1120
|
+
return
|
|
1121
|
+
|
|
1122
|
+
if not tokens:
|
|
1123
|
+
_debug_write("SKIPPED: empty command")
|
|
1124
|
+
return
|
|
1125
|
+
|
|
1126
|
+
# Check for dangerous shell constructs
|
|
1127
|
+
danger = find_dangerous_token(tokens)
|
|
1128
|
+
if danger:
|
|
1129
|
+
respond("ask", danger)
|
|
1130
|
+
return
|
|
1131
|
+
|
|
1132
|
+
# Split at command separators and validate each sub-command
|
|
1133
|
+
sub_commands = split_on_operators(tokens)
|
|
1134
|
+
if not sub_commands:
|
|
1135
|
+
_debug_write("SKIPPED: no sub-commands after splitting")
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
is_compound = len(sub_commands) > 1
|
|
1139
|
+
|
|
1140
|
+
decisions = []
|
|
1141
|
+
for parts in sub_commands:
|
|
1142
|
+
decision, reason = check_command(parts, cwd, config)
|
|
1143
|
+
decisions.append((decision, reason))
|
|
1144
|
+
|
|
1145
|
+
if not is_compound:
|
|
1146
|
+
# Single command: preserve current behavior (allow/ask/fallthrough)
|
|
1147
|
+
decision, reason = decisions[0]
|
|
1148
|
+
if decision:
|
|
1149
|
+
respond(decision, reason)
|
|
1150
|
+
else:
|
|
1151
|
+
_debug_write(f"FALLTHROUGH: no matching rule for {sub_commands[0][0]!r}")
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
# Compound command: all must be "allow" for the compound to be allowed
|
|
1155
|
+
for i, (decision, reason) in enumerate(decisions):
|
|
1156
|
+
if decision in ("ask", "deny"):
|
|
1157
|
+
respond(decision, reason)
|
|
1158
|
+
return
|
|
1159
|
+
if decision is None:
|
|
1160
|
+
_debug_write(f"FALLTHROUGH: no matching rule for {sub_commands[i][0]!r} in compound command")
|
|
1161
|
+
return
|
|
1162
|
+
|
|
1163
|
+
# All sub-commands returned "allow"
|
|
1164
|
+
reasons = [r for _, r in decisions]
|
|
1165
|
+
respond("allow", "All sub-commands allowed: " + "; ".join(reasons))
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def main():
|
|
1169
|
+
subcommand = sys.argv[1] if len(sys.argv) > 1 else None
|
|
1170
|
+
rest = sys.argv[2:]
|
|
1171
|
+
|
|
1172
|
+
if subcommand == "hook":
|
|
1173
|
+
cmd_hook(rest)
|
|
1174
|
+
elif subcommand == "install":
|
|
1175
|
+
cmd_install()
|
|
1176
|
+
elif subcommand == "validate":
|
|
1177
|
+
cmd_validate(rest)
|
|
1178
|
+
else:
|
|
1179
|
+
cmd_help()
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
if __name__ == "__main__":
|
|
1183
|
+
main()
|