hardstop 1.4.6 → 1.4.9

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.
@@ -1,272 +1,273 @@
1
- #!/usr/bin/env python3
2
- """
3
- Hardstop Plugin — Slash Command Handler
4
-
5
- Commands:
6
- /hs on Enable protection (default)
7
- /hs off Disable protection
8
- /hs skip [n] Skip next n commands (default: 1)
9
- /hs status Show current state
10
- /hs log Show recent audit log entries
11
- /hs help Show this help
12
- """
13
-
14
- import sys
15
- import io
16
- import json
17
- from pathlib import Path
18
- from datetime import datetime
19
- import os
20
- import tempfile
21
-
22
- # Fix Windows console encoding for Unicode output
23
- if sys.platform == "win32":
24
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
25
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
26
-
27
- STATE_DIR = Path.home() / ".hardstop"
28
- STATE_FILE = STATE_DIR / "state.json"
29
- SKIP_FILE = STATE_DIR / "skip_next"
30
- LOG_FILE = STATE_DIR / "audit.log"
31
- PLUGIN_DIR = Path.home() / ".claude" / "plugins" / "hs"
32
-
33
-
34
- def get_version() -> str:
35
- """Read version from plugin.json (single source of truth)."""
36
- plugin_json = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
37
- try:
38
- if plugin_json.exists():
39
- data = json.loads(plugin_json.read_text())
40
- return data.get("version", "unknown")
41
- except (json.JSONDecodeError, IOError):
42
- pass
43
- return "unknown"
44
-
45
-
46
- def load_state() -> dict:
47
- try:
48
- if STATE_FILE.exists():
49
- return json.loads(STATE_FILE.read_text())
50
- except json.JSONDecodeError:
51
- pass
52
- except (IOError, OSError):
53
- pass
54
- return {"enabled": True}
55
-
56
-
57
- def save_state(state: dict):
58
- try:
59
- STATE_DIR.mkdir(parents=True, exist_ok=True)
60
- payload = json.dumps({"enabled": bool(state.get("enabled", True))}, indent=2)
61
- with tempfile.NamedTemporaryFile(
62
- mode="w",
63
- encoding="utf-8",
64
- delete=False,
65
- dir=str(STATE_DIR),
66
- prefix="state.",
67
- suffix=".tmp",
68
- ) as tf:
69
- tf.write(payload)
70
- tmp_path = tf.name
71
- os.replace(tmp_path, STATE_FILE)
72
- except Exception as e:
73
- print(f"Error saving state: {e}", file=sys.stderr)
74
-
75
-
76
- def cmd_on():
77
- state = load_state()
78
- state["enabled"] = True
79
- save_state(state)
80
- print("✅ Hardstop enabled")
81
-
82
-
83
- def cmd_off():
84
- state = load_state()
85
- state["enabled"] = False
86
- save_state(state)
87
- print("⚠️ Hardstop disabled")
88
- print(" Dangerous commands will NOT be blocked.")
89
- print(" Note: Credential file protection (Read hook) remains active.")
90
- print(" Use '/hs on' to re-enable, or '/hs skip' to bypass read protection.")
91
-
92
-
93
- def cmd_skip(count: int = 1):
94
- """Set skip counter for next N commands."""
95
- if count < 1:
96
- print("❌ Skip count must be at least 1")
97
- return
98
- if count > 10:
99
- print("❌ Skip count cannot exceed 10 (safety limit)")
100
- return
101
-
102
- try:
103
- STATE_DIR.mkdir(parents=True, exist_ok=True)
104
- SKIP_FILE.write_text(str(count))
105
- except Exception as e:
106
- print(f"Error setting skip flag: {e}", file=sys.stderr)
107
- return
108
-
109
- if count == 1:
110
- print("⏭️ Next command will skip safety check")
111
- print(" One-time bypass protection resumes after.")
112
- else:
113
- print(f"⏭️ Next {count} commands will skip safety check")
114
- print(" Multi-skip bypass protection resumes after.")
115
-
116
-
117
- def cmd_status():
118
- state = load_state()
119
- enabled = state.get("enabled", True)
120
-
121
- # Check skip count
122
- skip_count = 0
123
- if SKIP_FILE.exists():
124
- try:
125
- skip_count = int(SKIP_FILE.read_text().strip())
126
- except (ValueError, IOError):
127
- skip_count = 1 # Fallback for old format
128
-
129
- print(f"Hardstop v{get_version()}")
130
- print()
131
- print(f" Status: {'🟢 Enabled' if enabled else '🔴 Disabled'}")
132
- if skip_count > 0:
133
- print(f" Skip next: {skip_count} command{'s' if skip_count > 1 else ''}")
134
- else:
135
- print(f" Skip next: No")
136
- print(f" Fail mode: Fail-closed (errors block commands)")
137
- print()
138
- print(f" State file: {STATE_FILE}")
139
- print(f" Skip file: {SKIP_FILE}")
140
- print(f" Audit log: {LOG_FILE}")
141
-
142
- # Show recent stats if log exists
143
- if LOG_FILE.exists():
144
- try:
145
- lines = LOG_FILE.read_text().strip().split('\n')
146
- recent = lines[-100:] # Last 100 entries
147
- blocks = sum(1 for l in recent if '"verdict": "BLOCK"' in l)
148
- allows = sum(1 for l in recent if '"verdict": "ALLOW"' in l)
149
- print()
150
- print(f" Recent stats (last {len(recent)} commands):")
151
- print(f" Blocked: {blocks}")
152
- print(f" Allowed: {allows}")
153
- except Exception:
154
- pass
155
-
156
- # GitHub star CTA
157
- print()
158
- print(" ⭐ Enjoying Hardstop? Star us on GitHub!")
159
- print(" https://github.com/frmoretto/hardstop")
160
-
161
-
162
- def cmd_log():
163
- """Show recent audit log entries."""
164
- if not LOG_FILE.exists():
165
- print("No audit log found yet.")
166
- print(f"Log will be created at: {LOG_FILE}")
167
- return
168
-
169
- try:
170
- lines = LOG_FILE.read_text().strip().split('\n')
171
- recent = lines[-20:] # Last 20 entries
172
-
173
- print(f"Hardstop Audit Log (last {len(recent)} entries)")
174
- print("=" * 60)
175
-
176
- for line in recent:
177
- try:
178
- entry = json.loads(line)
179
- ts = entry.get("timestamp", "")[:19] # Trim microseconds
180
- verdict = entry.get("verdict", "?")
181
- layer = entry.get("layer", "?")
182
- cmd = entry.get("command", "")[:50]
183
- reason = entry.get("reason", "")[:30]
184
-
185
- icon = "🛑" if verdict == "BLOCK" else "✅"
186
- print(f"{ts} {icon} [{layer:7}] {cmd}")
187
- if reason:
188
- print(f" └─ {reason}")
189
- except json.JSONDecodeError:
190
- continue
191
-
192
- print()
193
- print(f"Full log: {LOG_FILE}")
194
-
195
- except Exception as e:
196
- print(f"Error reading log: {e}")
197
-
198
-
199
- def cmd_help():
200
- print(f"""
201
- Hardstop v{get_version()}
202
- The mechanical brake for AI-generated commands
203
-
204
- Commands:
205
- /hs on Enable protection (default)
206
- /hs off Disable protection temporarily
207
- /hs skip [n] Skip safety check for next n commands (default: 1)
208
- /hs status Show current state and stats
209
- /hs log Show recent audit log entries
210
- /hs help Show this help
211
-
212
- Aliases: /hardstop, /hard, /hs
213
-
214
- What it catches:
215
- 🛑 Instant block: rm -rf ~/, fork bombs, reverse shells, credential exfil
216
- 🤖 LLM analysis: Obfuscated commands, novel attacks, context-dependent risks
217
-
218
- Design:
219
- • Fail-closed: If safety check fails, command is blocked (not allowed)
220
- Command chaining: Analyzes all parts of piped/chained commands
221
- Audit logging: All decisions logged to ~/.hardstop/audit.log
222
-
223
- Works independently — no skill required.
224
- """)
225
-
226
-
227
- def main():
228
- # Parse command
229
- if len(sys.argv) < 2:
230
- cmd_help()
231
- return
232
-
233
- subcommand = sys.argv[1].lower()
234
-
235
- # Handle skip with optional count argument
236
- if subcommand in ("skip", "bypass"):
237
- count = 1
238
- if len(sys.argv) >= 3:
239
- try:
240
- count = int(sys.argv[2])
241
- except ValueError:
242
- print(f"❌ Invalid skip count: {sys.argv[2]}")
243
- print(" Usage: /hs skip [count]")
244
- return
245
- cmd_skip(count)
246
- return
247
-
248
- commands = {
249
- "on": cmd_on,
250
- "enable": cmd_on,
251
- "off": cmd_off,
252
- "disable": cmd_off,
253
- "status": cmd_status,
254
- "state": cmd_status,
255
- "log": cmd_log,
256
- "logs": cmd_log,
257
- "audit": cmd_log,
258
- "help": cmd_help,
259
- "-h": cmd_help,
260
- "--help": cmd_help,
261
- }
262
-
263
- handler = commands.get(subcommand)
264
- if handler:
265
- handler()
266
- else:
267
- print(f"Unknown command: {subcommand}")
268
- print("Use '/hs help' for available commands.")
269
-
270
-
271
- if __name__ == "__main__":
272
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hardstop Plugin — Slash Command Handler
4
+
5
+ Commands:
6
+ /hs on Enable protection (default)
7
+ /hs off Disable protection
8
+ /hs skip [n] Skip next n commands (default: 1)
9
+ /hs status Show current state
10
+ /hs log Show recent audit log entries
11
+ /hs help Show this help
12
+ """
13
+
14
+ import sys
15
+ import io
16
+ import json
17
+ from pathlib import Path
18
+ from datetime import datetime
19
+ import os
20
+ import tempfile
21
+
22
+ # Fix Windows console encoding for Unicode output
23
+ if sys.platform == "win32":
24
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
25
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
26
+
27
+ STATE_DIR = Path.home() / ".hardstop"
28
+ STATE_FILE = STATE_DIR / "state.json"
29
+ SKIP_FILE = STATE_DIR / "skip_next"
30
+ LOG_FILE = STATE_DIR / "audit.log"
31
+ # Derive plugin dir from installed location: commands/ -> hs/
32
+ PLUGIN_DIR = Path(__file__).absolute().parent.parent
33
+
34
+
35
+ def get_version() -> str:
36
+ """Read version from plugin.json (single source of truth)."""
37
+ plugin_json = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
38
+ try:
39
+ if plugin_json.exists():
40
+ data = json.loads(plugin_json.read_text())
41
+ return data.get("version", "unknown")
42
+ except (json.JSONDecodeError, IOError):
43
+ pass
44
+ return "unknown"
45
+
46
+
47
+ def load_state() -> dict:
48
+ try:
49
+ if STATE_FILE.exists():
50
+ return json.loads(STATE_FILE.read_text())
51
+ except json.JSONDecodeError:
52
+ pass
53
+ except (IOError, OSError):
54
+ pass
55
+ return {"enabled": True}
56
+
57
+
58
+ def save_state(state: dict):
59
+ try:
60
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
61
+ payload = json.dumps({"enabled": bool(state.get("enabled", True))}, indent=2)
62
+ with tempfile.NamedTemporaryFile(
63
+ mode="w",
64
+ encoding="utf-8",
65
+ delete=False,
66
+ dir=str(STATE_DIR),
67
+ prefix="state.",
68
+ suffix=".tmp",
69
+ ) as tf:
70
+ tf.write(payload)
71
+ tmp_path = tf.name
72
+ os.replace(tmp_path, STATE_FILE)
73
+ except Exception as e:
74
+ print(f"Error saving state: {e}", file=sys.stderr)
75
+
76
+
77
+ def cmd_on():
78
+ state = load_state()
79
+ state["enabled"] = True
80
+ save_state(state)
81
+ print("✅ Hardstop enabled")
82
+
83
+
84
+ def cmd_off():
85
+ state = load_state()
86
+ state["enabled"] = False
87
+ save_state(state)
88
+ print("⚠️ Hardstop disabled")
89
+ print(" Dangerous commands will NOT be blocked.")
90
+ print(" Note: Credential file protection (Read hook) remains active.")
91
+ print(" Use '/hs on' to re-enable, or '/hs skip' to bypass read protection.")
92
+
93
+
94
+ def cmd_skip(count: int = 1):
95
+ """Set skip counter for next N commands."""
96
+ if count < 1:
97
+ print("❌ Skip count must be at least 1")
98
+ return
99
+ if count > 10:
100
+ print("❌ Skip count cannot exceed 10 (safety limit)")
101
+ return
102
+
103
+ try:
104
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
105
+ SKIP_FILE.write_text(str(count))
106
+ except Exception as e:
107
+ print(f"Error setting skip flag: {e}", file=sys.stderr)
108
+ return
109
+
110
+ if count == 1:
111
+ print("⏭️ Next command will skip safety check")
112
+ print(" One-time bypass — protection resumes after.")
113
+ else:
114
+ print(f"⏭️ Next {count} commands will skip safety check")
115
+ print(" Multi-skip bypass — protection resumes after.")
116
+
117
+
118
+ def cmd_status():
119
+ state = load_state()
120
+ enabled = state.get("enabled", True)
121
+
122
+ # Check skip count
123
+ skip_count = 0
124
+ if SKIP_FILE.exists():
125
+ try:
126
+ skip_count = int(SKIP_FILE.read_text().strip())
127
+ except (ValueError, IOError):
128
+ skip_count = 1 # Fallback for old format
129
+
130
+ print(f"Hardstop v{get_version()}")
131
+ print()
132
+ print(f" Status: {'🟢 Enabled' if enabled else '🔴 Disabled'}")
133
+ if skip_count > 0:
134
+ print(f" Skip next: {skip_count} command{'s' if skip_count > 1 else ''}")
135
+ else:
136
+ print(f" Skip next: No")
137
+ print(f" Fail mode: Fail-closed (errors block commands)")
138
+ print()
139
+ print(f" State file: {STATE_FILE}")
140
+ print(f" Skip file: {SKIP_FILE}")
141
+ print(f" Audit log: {LOG_FILE}")
142
+
143
+ # Show recent stats if log exists
144
+ if LOG_FILE.exists():
145
+ try:
146
+ lines = LOG_FILE.read_text().strip().split('\n')
147
+ recent = lines[-100:] # Last 100 entries
148
+ blocks = sum(1 for l in recent if '"verdict": "BLOCK"' in l)
149
+ allows = sum(1 for l in recent if '"verdict": "ALLOW"' in l)
150
+ print()
151
+ print(f" Recent stats (last {len(recent)} commands):")
152
+ print(f" Blocked: {blocks}")
153
+ print(f" Allowed: {allows}")
154
+ except Exception:
155
+ pass
156
+
157
+ # GitHub star CTA
158
+ print()
159
+ print(" ⭐ Enjoying Hardstop? Star us on GitHub!")
160
+ print(" https://github.com/frmoretto/hardstop")
161
+
162
+
163
+ def cmd_log():
164
+ """Show recent audit log entries."""
165
+ if not LOG_FILE.exists():
166
+ print("No audit log found yet.")
167
+ print(f"Log will be created at: {LOG_FILE}")
168
+ return
169
+
170
+ try:
171
+ lines = LOG_FILE.read_text().strip().split('\n')
172
+ recent = lines[-20:] # Last 20 entries
173
+
174
+ print(f"Hardstop Audit Log (last {len(recent)} entries)")
175
+ print("=" * 60)
176
+
177
+ for line in recent:
178
+ try:
179
+ entry = json.loads(line)
180
+ ts = entry.get("timestamp", "")[:19] # Trim microseconds
181
+ verdict = entry.get("verdict", "?")
182
+ layer = entry.get("layer", "?")
183
+ cmd = entry.get("command", "")[:50]
184
+ reason = entry.get("reason", "")[:30]
185
+
186
+ icon = "🛑" if verdict == "BLOCK" else "✅"
187
+ print(f"{ts} {icon} [{layer:7}] {cmd}")
188
+ if reason:
189
+ print(f" └─ {reason}")
190
+ except json.JSONDecodeError:
191
+ continue
192
+
193
+ print()
194
+ print(f"Full log: {LOG_FILE}")
195
+
196
+ except Exception as e:
197
+ print(f"Error reading log: {e}")
198
+
199
+
200
+ def cmd_help():
201
+ print(f"""
202
+ Hardstop v{get_version()}
203
+ The mechanical brake for AI-generated commands
204
+
205
+ Commands:
206
+ /hs on Enable protection (default)
207
+ /hs off Disable protection temporarily
208
+ /hs skip [n] Skip safety check for next n commands (default: 1)
209
+ /hs status Show current state and stats
210
+ /hs log Show recent audit log entries
211
+ /hs help Show this help
212
+
213
+ Aliases: /hardstop, /hard, /hs
214
+
215
+ What it catches:
216
+ 🛑 Instant block: rm -rf ~/, fork bombs, reverse shells, credential exfil
217
+ 🤖 LLM analysis: Obfuscated commands, novel attacks, context-dependent risks
218
+
219
+ Design:
220
+ Fail-closed: If safety check fails, command is blocked (not allowed)
221
+ Command chaining: Analyzes all parts of piped/chained commands
222
+ • Audit logging: All decisions logged to ~/.hardstop/audit.log
223
+
224
+ Works independently — no skill required.
225
+ """)
226
+
227
+
228
+ def main():
229
+ # Parse command
230
+ if len(sys.argv) < 2:
231
+ cmd_help()
232
+ return
233
+
234
+ subcommand = sys.argv[1].lower()
235
+
236
+ # Handle skip with optional count argument
237
+ if subcommand in ("skip", "bypass"):
238
+ count = 1
239
+ if len(sys.argv) >= 3:
240
+ try:
241
+ count = int(sys.argv[2])
242
+ except ValueError:
243
+ print(f" Invalid skip count: {sys.argv[2]}")
244
+ print(" Usage: /hs skip [count]")
245
+ return
246
+ cmd_skip(count)
247
+ return
248
+
249
+ commands = {
250
+ "on": cmd_on,
251
+ "enable": cmd_on,
252
+ "off": cmd_off,
253
+ "disable": cmd_off,
254
+ "status": cmd_status,
255
+ "state": cmd_status,
256
+ "log": cmd_log,
257
+ "logs": cmd_log,
258
+ "audit": cmd_log,
259
+ "help": cmd_help,
260
+ "-h": cmd_help,
261
+ "--help": cmd_help,
262
+ }
263
+
264
+ handler = commands.get(subcommand)
265
+ if handler:
266
+ handler()
267
+ else:
268
+ print(f"Unknown command: {subcommand}")
269
+ print("Use '/hs help' for available commands.")
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
@@ -102,14 +102,18 @@ DANGEROUS_PATTERNS = _load_dangerous_patterns()
102
102
 
103
103
  # Note: Legacy hardcoded patterns removed in v1.4.0 (now in patterns/dangerous_commands.yaml)
104
104
 
105
+ # Derive config dir from installed location: hooks/ -> hs/ -> plugins/ -> config dir
106
+ _CLAUDE_DIR = str(Path(__file__).absolute().parent.parent.parent.parent)
107
+ _CLAUDE_DIR_RE = re.escape(_CLAUDE_DIR)
108
+
105
109
  SAFE_PATTERNS = [
106
110
  # Hardstop's own operations (must be able to manage itself)
107
- r"^python\s+.*[/\\]\.claude[/\\]plugins[/\\]hs[/\\].*\.py(?:\s+.*)?$",
111
+ rf"^python\s+.*{_CLAUDE_DIR_RE}[/\\]plugins[/\\]hs[/\\].*\.py(?:\s+.*)?$",
108
112
  r"^python\s+.*\.hardstop.*$",
109
113
  r"^cat\s+.*\.hardstop[/\\].*$",
110
- r"^cat\s+.*\.claude[/\\]plugins[/\\]hs[/\\].*$",
114
+ rf"^cat\s+.*{_CLAUDE_DIR_RE}[/\\]plugins[/\\]hs[/\\].*$",
111
115
  r"^rm\s+(-f\s+)?.*\.hardstop[/\\](skip_next|hook_debug\.log)$",
112
- r"^grep\s+.*\.claude[/\\]plugins[/\\]hs[/\\].*$",
116
+ rf"^grep\s+.*{_CLAUDE_DIR_RE}[/\\]plugins[/\\]hs[/\\].*$",
113
117
 
114
118
  # Read-only operations
115
119
  r"^ls(?:\s+.*)?$",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hardstop",
3
- "version": "1.4.6",
3
+ "version": "1.4.9",
4
4
  "description": "Pre-execution safety layer for Claude Code - blocks dangerous commands before they run. Part of the Hardstop ecosystem.",
5
5
  "keywords": [
6
6
  "claude-code",