hardstop 0.0.1 → 1.4.0
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-plugin/marketplace.json +72 -0
- package/.claude-plugin/plugin.json +25 -0
- package/CHANGELOG.md +336 -0
- package/LICENSE +13 -0
- package/README.md +364 -34
- package/bin/install.js +310 -0
- package/commands/hs.md +66 -0
- package/commands/hs_cmd.py +267 -0
- package/commands/log.md +23 -0
- package/commands/off.md +18 -0
- package/commands/on.md +18 -0
- package/commands/skip.md +18 -0
- package/commands/status.md +18 -0
- package/hooks/hooks.json +36 -0
- package/hooks/pattern_loader.py +180 -0
- package/hooks/pre_read.py +590 -0
- package/hooks/pre_tool_use.py +891 -0
- package/hooks/risk_scoring.py +151 -0
- package/hooks/session_tracker.py +246 -0
- package/package.json +39 -16
- package/patterns/dangerous_commands.yaml +1081 -0
- package/patterns/dangerous_reads.yaml +427 -0
- package/patterns/safe_commands.yaml +1 -0
- package/patterns/safe_reads.yaml +1 -0
- package/patterns/schema.json +96 -0
- package/patterns/sensitive_reads.yaml +67 -0
- package/skills/hs/SKILL.md +535 -0
- package/index.js +0 -15
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Risk scoring system for command execution safety.
|
|
4
|
+
|
|
5
|
+
Tracks cumulative risk score per session based on blocked commands.
|
|
6
|
+
Each blocked command contributes to a session risk score based on its severity.
|
|
7
|
+
|
|
8
|
+
Part of Hardstop v1.4.0 - Phase 2.3: Risk Scoring System
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Dict, Tuple
|
|
12
|
+
|
|
13
|
+
# Severity weights: points added to risk score when command is blocked
|
|
14
|
+
SEVERITY_WEIGHTS = {
|
|
15
|
+
"critical": 25, # Fork bomb, rm -rf /, credential theft
|
|
16
|
+
"high": 15, # Reverse shell, sudo abuse, network exfiltration
|
|
17
|
+
"medium": 10, # Config changes, overly permissive permissions
|
|
18
|
+
"low": 5, # Suspicious but likely benign
|
|
19
|
+
"info": 1, # Logged but not concerning
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Risk thresholds: ranges that define overall session risk level
|
|
23
|
+
RISK_THRESHOLDS = {
|
|
24
|
+
"low": (0, 24), # 0-24: minimal risk
|
|
25
|
+
"moderate": (25, 49), # 25-49: some concerning patterns
|
|
26
|
+
"high": (50, 74), # 50-74: multiple dangerous attempts
|
|
27
|
+
"critical": (75, float('inf')), # 75+: sustained attack pattern
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def calculate_risk_level(score: int) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Calculate risk level from numeric score.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
score: Cumulative risk score (sum of severity weights)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Risk level: "low", "moderate", "high", or "critical"
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> calculate_risk_level(10)
|
|
43
|
+
'low'
|
|
44
|
+
>>> calculate_risk_level(30)
|
|
45
|
+
'moderate'
|
|
46
|
+
>>> calculate_risk_level(60)
|
|
47
|
+
'high'
|
|
48
|
+
>>> calculate_risk_level(100)
|
|
49
|
+
'critical'
|
|
50
|
+
"""
|
|
51
|
+
for level, (min_score, max_score) in RISK_THRESHOLDS.items():
|
|
52
|
+
if min_score <= score <= max_score:
|
|
53
|
+
return level
|
|
54
|
+
return "unknown"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_severity_weight(severity: str) -> int:
|
|
58
|
+
"""
|
|
59
|
+
Get numeric weight for a severity level.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
severity: Severity level string
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Weight (points to add to risk score)
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
>>> get_severity_weight("critical")
|
|
69
|
+
25
|
|
70
|
+
>>> get_severity_weight("high")
|
|
71
|
+
15
|
|
72
|
+
>>> get_severity_weight("invalid")
|
|
73
|
+
0
|
|
74
|
+
"""
|
|
75
|
+
return SEVERITY_WEIGHTS.get(severity.lower(), 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_risk_color(level: str) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Get ANSI color code for risk level.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
level: Risk level string
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ANSI color code
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
>>> get_risk_color("critical")
|
|
90
|
+
'\\033[91m'
|
|
91
|
+
>>> get_risk_color("low")
|
|
92
|
+
'\\033[92m'
|
|
93
|
+
"""
|
|
94
|
+
colors = {
|
|
95
|
+
"low": "\033[92m", # Green
|
|
96
|
+
"moderate": "\033[93m", # Yellow
|
|
97
|
+
"high": "\033[91m", # Red
|
|
98
|
+
"critical": "\033[95m", # Magenta
|
|
99
|
+
}
|
|
100
|
+
return colors.get(level.lower(), "\033[0m")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_risk_display(score: int, level: str) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Format risk score and level for display.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
score: Numeric risk score
|
|
109
|
+
level: Risk level string
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Formatted string with color
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
>>> format_risk_display(35, "moderate")
|
|
116
|
+
'\\033[93mMODERATE\\033[0m (35 points)'
|
|
117
|
+
"""
|
|
118
|
+
color = get_risk_color(level)
|
|
119
|
+
reset = "\033[0m"
|
|
120
|
+
return f"{color}{level.upper()}{reset} ({score} points)"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_risk_description(level: str) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Get human-readable description of risk level.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
level: Risk level string
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Description of what this risk level means
|
|
132
|
+
"""
|
|
133
|
+
descriptions = {
|
|
134
|
+
"low": "Minimal risk detected. Normal usage patterns.",
|
|
135
|
+
"moderate": "Some concerning patterns detected. Review blocked commands.",
|
|
136
|
+
"high": "Multiple dangerous attempts detected. Potential security threat.",
|
|
137
|
+
"critical": "Sustained attack pattern detected. Immediate review recommended.",
|
|
138
|
+
}
|
|
139
|
+
return descriptions.get(level.lower(), "Unknown risk level.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Export all public functions
|
|
143
|
+
__all__ = [
|
|
144
|
+
'SEVERITY_WEIGHTS',
|
|
145
|
+
'RISK_THRESHOLDS',
|
|
146
|
+
'calculate_risk_level',
|
|
147
|
+
'get_severity_weight',
|
|
148
|
+
'get_risk_color',
|
|
149
|
+
'format_risk_display',
|
|
150
|
+
'get_risk_description',
|
|
151
|
+
]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Session-based risk tracking for Hardstop.
|
|
4
|
+
|
|
5
|
+
Tracks blocked commands and cumulative risk score per session.
|
|
6
|
+
Persists data to ~/.hardstop/session.json
|
|
7
|
+
|
|
8
|
+
Part of Hardstop v1.4.0 - Phase 2.3: Risk Scoring System
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import List, Dict, Optional
|
|
16
|
+
from risk_scoring import SEVERITY_WEIGHTS, calculate_risk_level, get_severity_weight
|
|
17
|
+
|
|
18
|
+
HARDSTOP_DIR = Path.home() / ".hardstop"
|
|
19
|
+
SESSION_FILE = HARDSTOP_DIR / "session.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionTracker:
|
|
23
|
+
"""Track session risk and blocked commands."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
"""Initialize session tracker."""
|
|
27
|
+
self.session_id = self._get_session_id()
|
|
28
|
+
self.data = self._load_session()
|
|
29
|
+
|
|
30
|
+
def _get_session_id(self) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Get or create session ID.
|
|
33
|
+
|
|
34
|
+
Uses HARDSTOP_SESSION_ID environment variable if set,
|
|
35
|
+
otherwise creates a new session ID from current timestamp.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Session ID string
|
|
39
|
+
"""
|
|
40
|
+
session_id = os.environ.get('HARDSTOP_SESSION_ID')
|
|
41
|
+
if not session_id:
|
|
42
|
+
session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
43
|
+
os.environ['HARDSTOP_SESSION_ID'] = session_id
|
|
44
|
+
return session_id
|
|
45
|
+
|
|
46
|
+
def _load_session(self) -> Dict:
|
|
47
|
+
"""
|
|
48
|
+
Load session data from disk.
|
|
49
|
+
|
|
50
|
+
If session file exists and session ID matches, loads existing data.
|
|
51
|
+
Otherwise creates a new session.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Session data dictionary
|
|
55
|
+
"""
|
|
56
|
+
if SESSION_FILE.exists():
|
|
57
|
+
try:
|
|
58
|
+
with open(SESSION_FILE) as f:
|
|
59
|
+
data = json.load(f)
|
|
60
|
+
# Reset if session ID changed
|
|
61
|
+
if data.get('session_id') != self.session_id:
|
|
62
|
+
return self._create_new_session()
|
|
63
|
+
return data
|
|
64
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
65
|
+
# If file is corrupted, create new session
|
|
66
|
+
print(f"Warning: Failed to load session data: {e}", flush=True)
|
|
67
|
+
return self._create_new_session()
|
|
68
|
+
return self._create_new_session()
|
|
69
|
+
|
|
70
|
+
def _create_new_session(self) -> Dict:
|
|
71
|
+
"""
|
|
72
|
+
Create new session data structure.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
New session dictionary with initial values
|
|
76
|
+
"""
|
|
77
|
+
return {
|
|
78
|
+
'session_id': self.session_id,
|
|
79
|
+
'started_at': datetime.now().isoformat(),
|
|
80
|
+
'risk_score': 0,
|
|
81
|
+
'blocked_commands': [],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def _save_session(self):
|
|
85
|
+
"""
|
|
86
|
+
Persist session data to disk.
|
|
87
|
+
|
|
88
|
+
Creates ~/.hardstop directory if it doesn't exist.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
HARDSTOP_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with open(SESSION_FILE, 'w') as f:
|
|
93
|
+
json.dump(self.data, f, indent=2)
|
|
94
|
+
except IOError as e:
|
|
95
|
+
print(f"Warning: Failed to save session data: {e}", flush=True)
|
|
96
|
+
|
|
97
|
+
def record_block(self, command: str, pattern_data: Dict):
|
|
98
|
+
"""
|
|
99
|
+
Record a blocked command and update risk score.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
command: The command that was blocked
|
|
103
|
+
pattern_data: Pattern information including severity, message, etc.
|
|
104
|
+
"""
|
|
105
|
+
severity = pattern_data.get('severity', 'medium')
|
|
106
|
+
weight = get_severity_weight(severity)
|
|
107
|
+
|
|
108
|
+
block_record = {
|
|
109
|
+
'timestamp': datetime.now().isoformat(),
|
|
110
|
+
'command': command[:200], # Truncate long commands
|
|
111
|
+
'severity': severity,
|
|
112
|
+
'weight': weight,
|
|
113
|
+
'message': pattern_data.get('message', ''),
|
|
114
|
+
'pattern_id': pattern_data.get('id', ''),
|
|
115
|
+
'mitre_attack': pattern_data.get('mitre_attack'),
|
|
116
|
+
'category': pattern_data.get('category'),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
self.data['blocked_commands'].append(block_record)
|
|
120
|
+
self.data['risk_score'] += weight
|
|
121
|
+
self.data['last_blocked_at'] = datetime.now().isoformat()
|
|
122
|
+
self._save_session()
|
|
123
|
+
|
|
124
|
+
def get_risk_score(self) -> int:
|
|
125
|
+
"""
|
|
126
|
+
Get current session risk score.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Current risk score (cumulative weight of all blocked commands)
|
|
130
|
+
"""
|
|
131
|
+
return self.data.get('risk_score', 0)
|
|
132
|
+
|
|
133
|
+
def get_risk_level(self) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Get current risk level based on score.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Risk level: "low", "moderate", "high", or "critical"
|
|
139
|
+
"""
|
|
140
|
+
return calculate_risk_level(self.get_risk_score())
|
|
141
|
+
|
|
142
|
+
def get_blocked_commands(self) -> List[Dict]:
|
|
143
|
+
"""
|
|
144
|
+
Get list of blocked commands this session.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of blocked command records
|
|
148
|
+
"""
|
|
149
|
+
return self.data.get('blocked_commands', [])
|
|
150
|
+
|
|
151
|
+
def get_blocked_count(self) -> int:
|
|
152
|
+
"""
|
|
153
|
+
Get count of blocked commands.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Number of commands blocked this session
|
|
157
|
+
"""
|
|
158
|
+
return len(self.data.get('blocked_commands', []))
|
|
159
|
+
|
|
160
|
+
def get_session_info(self) -> Dict:
|
|
161
|
+
"""
|
|
162
|
+
Get complete session information.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dictionary with session_id, started_at, risk_score, risk_level, blocked_count
|
|
166
|
+
"""
|
|
167
|
+
return {
|
|
168
|
+
'session_id': self.session_id,
|
|
169
|
+
'started_at': self.data.get('started_at'),
|
|
170
|
+
'last_blocked_at': self.data.get('last_blocked_at'),
|
|
171
|
+
'risk_score': self.get_risk_score(),
|
|
172
|
+
'risk_level': self.get_risk_level(),
|
|
173
|
+
'blocked_count': self.get_blocked_count(),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def get_severity_breakdown(self) -> Dict[str, int]:
|
|
177
|
+
"""
|
|
178
|
+
Get breakdown of blocked commands by severity.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Dictionary mapping severity levels to counts
|
|
182
|
+
"""
|
|
183
|
+
breakdown = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'info': 0}
|
|
184
|
+
for cmd in self.get_blocked_commands():
|
|
185
|
+
severity = cmd.get('severity', 'medium')
|
|
186
|
+
if severity in breakdown:
|
|
187
|
+
breakdown[severity] += 1
|
|
188
|
+
return breakdown
|
|
189
|
+
|
|
190
|
+
def reset_session(self):
|
|
191
|
+
"""
|
|
192
|
+
Reset session data.
|
|
193
|
+
|
|
194
|
+
Creates a new session with fresh ID and zero risk score.
|
|
195
|
+
"""
|
|
196
|
+
new_session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
197
|
+
os.environ['HARDSTOP_SESSION_ID'] = new_session_id
|
|
198
|
+
self.session_id = new_session_id
|
|
199
|
+
|
|
200
|
+
self.data = {
|
|
201
|
+
'session_id': self.session_id,
|
|
202
|
+
'started_at': datetime.now().isoformat(),
|
|
203
|
+
'risk_score': 0,
|
|
204
|
+
'blocked_commands': [],
|
|
205
|
+
}
|
|
206
|
+
self._save_session()
|
|
207
|
+
|
|
208
|
+
def clear_history(self):
|
|
209
|
+
"""
|
|
210
|
+
Clear blocked commands history but keep current session.
|
|
211
|
+
|
|
212
|
+
Resets risk score and command list while maintaining session ID.
|
|
213
|
+
"""
|
|
214
|
+
self.data['risk_score'] = 0
|
|
215
|
+
self.data['blocked_commands'] = []
|
|
216
|
+
if 'last_blocked_at' in self.data:
|
|
217
|
+
del self.data['last_blocked_at']
|
|
218
|
+
self._save_session()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Global tracker instance (singleton)
|
|
222
|
+
_tracker: Optional[SessionTracker] = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_tracker() -> SessionTracker:
|
|
226
|
+
"""
|
|
227
|
+
Get global session tracker instance.
|
|
228
|
+
|
|
229
|
+
Creates tracker on first call, returns existing instance on subsequent calls.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
SessionTracker instance
|
|
233
|
+
"""
|
|
234
|
+
global _tracker
|
|
235
|
+
if _tracker is None:
|
|
236
|
+
_tracker = SessionTracker()
|
|
237
|
+
return _tracker
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Export public API
|
|
241
|
+
__all__ = [
|
|
242
|
+
'SessionTracker',
|
|
243
|
+
'get_tracker',
|
|
244
|
+
'HARDSTOP_DIR',
|
|
245
|
+
'SESSION_FILE',
|
|
246
|
+
]
|
package/package.json
CHANGED
|
@@ -1,29 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hardstop",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "index.js",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Pre-execution safety layer for Claude Code - blocks dangerous commands before they run",
|
|
6
5
|
"keywords": [
|
|
7
|
-
"hardstop",
|
|
8
|
-
"ai-safety",
|
|
9
|
-
"command-validation",
|
|
10
|
-
"security",
|
|
11
6
|
"claude-code",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
7
|
+
"plugin",
|
|
8
|
+
"safety",
|
|
9
|
+
"security",
|
|
10
|
+
"command-safety",
|
|
11
|
+
"shell-protection",
|
|
12
|
+
"ai-safety",
|
|
13
|
+
"mitre-attack",
|
|
14
|
+
"risk-scoring"
|
|
18
15
|
],
|
|
19
|
-
"author":
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Francesco Marinoni Moretto",
|
|
18
|
+
"email": "contact@clarity-gate.org"
|
|
19
|
+
},
|
|
20
20
|
"license": "CC-BY-4.0",
|
|
21
|
+
"homepage": "https://github.com/frmoretto/hardstop",
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
|
-
"url": "
|
|
24
|
+
"url": "https://github.com/frmoretto/hardstop.git"
|
|
24
25
|
},
|
|
25
26
|
"bugs": {
|
|
26
27
|
"url": "https://github.com/frmoretto/hardstop/issues"
|
|
27
28
|
},
|
|
28
|
-
"
|
|
29
|
+
"bin": {
|
|
30
|
+
"hardstop": "./bin/install.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
".claude-plugin/",
|
|
34
|
+
"hooks/",
|
|
35
|
+
"commands/",
|
|
36
|
+
"patterns/",
|
|
37
|
+
"bin/",
|
|
38
|
+
"skills/",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=16.7.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "echo \"Use pytest for testing: cd hardstop && pytest tests/\"",
|
|
48
|
+
"postinstall": "echo \"Run 'npx hardstop install' to install the plugin\""
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {},
|
|
51
|
+
"devDependencies": {}
|
|
29
52
|
}
|