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()