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.
- package/.claude/skills/hs/SKILL.md +28 -2
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex/skills/hs/SKILL.md +28 -10
- package/.github/skills/hs/SKILL.md +28 -10
- package/CHANGELOG.md +37 -0
- package/bin/install.js +322 -310
- package/commands/__pycache__/hs_cmd.cpython-313.pyc +0 -0
- package/commands/hs_cmd.py +273 -272
- package/hooks/pre_tool_use.py +7 -3
- package/package.json +1 -1
- package/skills/hs/SKILL.md +34 -6
package/commands/hs_cmd.py
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
state
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
state
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
print("
|
|
89
|
-
print("
|
|
90
|
-
print("
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
print("
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
print("
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
print()
|
|
131
|
-
print(
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
print()
|
|
138
|
-
print(
|
|
139
|
-
print(f"
|
|
140
|
-
print(f"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
print(
|
|
151
|
-
print(f"
|
|
152
|
-
print(f"
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
print(
|
|
159
|
-
print("
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
print(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
print("
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
print(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
/hs
|
|
207
|
-
/hs
|
|
208
|
-
/hs
|
|
209
|
-
/hs
|
|
210
|
-
/hs
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
•
|
|
221
|
-
•
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
print("
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
"
|
|
251
|
-
"
|
|
252
|
-
"
|
|
253
|
-
"
|
|
254
|
-
"
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"
|
|
258
|
-
"
|
|
259
|
-
"
|
|
260
|
-
"
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
print("
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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()
|
package/hooks/pre_tool_use.py
CHANGED
|
@@ -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
|
-
|
|
111
|
+
rf"^python\s+.*{_CLAUDE_DIR_RE}[/\\]plugins[/\\]hs[/\\].*\.py(?:\s+.*)?$",
|
|
108
112
|
r"^python\s+.*\.hardstop.*$",
|
|
109
113
|
r"^cat\s+.*\.hardstop[/\\].*$",
|
|
110
|
-
|
|
114
|
+
rf"^cat\s+.*{_CLAUDE_DIR_RE}[/\\]plugins[/\\]hs[/\\].*$",
|
|
111
115
|
r"^rm\s+(-f\s+)?.*\.hardstop[/\\](skip_next|hook_debug\.log)$",
|
|
112
|
-
|
|
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