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.
@@ -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": "0.0.1",
4
- "description": "The mechanical brake for AI-generated commands. Pre-execution safety validation for Claude Code.",
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
- "mcp",
13
- "pre-execution",
14
- "fail-closed",
15
- "llm",
16
- "bash",
17
- "shell"
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": "frmoretto <francesco.marinoni.moretto@gmail.com>",
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": "git+https://github.com/frmoretto/hardstop.git"
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
- "homepage": "https://github.com/frmoretto/hardstop"
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
  }