@jaguilar87/gaia-ops 1.0.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/CHANGELOG.md +315 -0
- package/CLAUDE.md +154 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/agents/aws-troubleshooter.md +50 -0
- package/agents/claude-architect.md +821 -0
- package/agents/devops-developer.md +92 -0
- package/agents/gcp-troubleshooter.md +50 -0
- package/agents/gitops-operator.md +360 -0
- package/agents/terraform-architect.md +289 -0
- package/bin/gaia-init.js +620 -0
- package/commands/architect.md +97 -0
- package/commands/restore-session.md +87 -0
- package/commands/save-session.md +88 -0
- package/commands/session-status.md +61 -0
- package/commands/speckit.add-task.md +144 -0
- package/commands/speckit.analyze-task.md +65 -0
- package/commands/speckit.implement.md +96 -0
- package/commands/speckit.init.md +237 -0
- package/commands/speckit.plan.md +88 -0
- package/commands/speckit.specify.md +161 -0
- package/commands/speckit.tasks.md +188 -0
- package/config/AGENTS.md +162 -0
- package/config/agent-catalog.md +604 -0
- package/config/context-contracts.md +682 -0
- package/config/git-standards.md +674 -0
- package/config/git_standards.json +69 -0
- package/config/orchestration-workflow.md +735 -0
- package/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/hooks/__pycache__/pre_kubectl_security.cpython-312.pyc +0 -0
- package/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/hooks/__pycache__/subagent_stop.cpython-312.pyc +0 -0
- package/hooks/post_tool_use.py +463 -0
- package/hooks/pre_kubectl_security.py +205 -0
- package/hooks/pre_tool_use.py +530 -0
- package/hooks/session_start.py +315 -0
- package/hooks/subagent_stop.py +549 -0
- package/index.js +92 -0
- package/package.json +59 -0
- package/speckit/README.en.md +648 -0
- package/speckit/README.md +353 -0
- package/speckit/governance.md +169 -0
- package/speckit/scripts/check-prerequisites.sh +194 -0
- package/speckit/scripts/common.sh +126 -0
- package/speckit/scripts/create-new-feature.sh +131 -0
- package/speckit/scripts/init.sh +42 -0
- package/speckit/scripts/setup-plan.sh +95 -0
- package/speckit/scripts/update-agent-context.sh +718 -0
- package/speckit/templates/adr-template.md +118 -0
- package/speckit/templates/agent-file-template.md +23 -0
- package/speckit/templates/plan-template.md +233 -0
- package/speckit/templates/spec-template.md +116 -0
- package/speckit/templates/tasks-template-bkp.md +136 -0
- package/speckit/templates/tasks-template.md +345 -0
- package/templates/CLAUDE.template.md +170 -0
- package/templates/code-examples/approval_gate_workflow.py +141 -0
- package/templates/code-examples/clarification_workflow.py +94 -0
- package/templates/code-examples/commit_validation.py +86 -0
- package/templates/project-context.template.json +126 -0
- package/templates/settings.template.json +307 -0
- package/tools/__pycache__/agent_router.cpython-312.pyc +0 -0
- package/tools/__pycache__/approval_gate.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_engine.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_patterns.cpython-312.pyc +0 -0
- package/tools/__pycache__/commit_validator.cpython-312.pyc +0 -0
- package/tools/__pycache__/context_section_reader.cpython-312.pyc +0 -0
- package/tools/__pycache__/routing_dashboard.cpython-312.pyc +0 -0
- package/tools/__pycache__/routing_feedback.cpython-312.pyc +0 -0
- package/tools/__pycache__/semantic_matcher.cpython-312.pyc +0 -0
- package/tools/__pycache__/task_manager.cpython-312.pyc +0 -0
- package/tools/agent_capabilities.json +231 -0
- package/tools/agent_invoker_helper.py +239 -0
- package/tools/agent_router.py +730 -0
- package/tools/approval_gate.py +318 -0
- package/tools/clarify_engine.py +511 -0
- package/tools/clarify_patterns.py +356 -0
- package/tools/commit_validator.py +338 -0
- package/tools/context_provider.py +181 -0
- package/tools/context_section_reader.py +301 -0
- package/tools/demo_clarify.py +104 -0
- package/tools/generate_embeddings.py +168 -0
- package/tools/quicktriage_aws_troubleshooter.sh +45 -0
- package/tools/quicktriage_devops_developer.sh +38 -0
- package/tools/quicktriage_gcp_troubleshooter.sh +51 -0
- package/tools/quicktriage_gitops_operator.sh +47 -0
- package/tools/quicktriage_terraform_architect.sh +40 -0
- package/tools/semantic_matcher.py +222 -0
- package/tools/task_manager.py +547 -0
- package/tools/task_manager_README.md +395 -0
- package/tools/task_manager_example.py +215 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Post-tool use hook for Claude Code Agent System
|
|
4
|
+
Implements audit logging, metrics collection, and session tracking
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Dict, List, Optional, Any
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
21
|
+
)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
def find_claude_dir() -> Path:
|
|
25
|
+
"""Find the .claude directory by searching upward from current location"""
|
|
26
|
+
current = Path.cwd()
|
|
27
|
+
|
|
28
|
+
# If we're already in a .claude directory, return it
|
|
29
|
+
if current.name == ".claude":
|
|
30
|
+
return current
|
|
31
|
+
|
|
32
|
+
# Look for .claude in current directory
|
|
33
|
+
claude_dir = current / ".claude"
|
|
34
|
+
if claude_dir.exists():
|
|
35
|
+
return claude_dir
|
|
36
|
+
|
|
37
|
+
# Search upward through parent directories
|
|
38
|
+
for parent in current.parents:
|
|
39
|
+
claude_dir = parent / ".claude"
|
|
40
|
+
if claude_dir.exists():
|
|
41
|
+
return claude_dir
|
|
42
|
+
|
|
43
|
+
# Fallback - use current directory's .claude (but don't create it yet)
|
|
44
|
+
logger.warning(f"No .claude directory found, using {current}/.claude")
|
|
45
|
+
return current / ".claude"
|
|
46
|
+
|
|
47
|
+
class AuditLogger:
|
|
48
|
+
"""Audit logger for tracking all tool executions"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, log_dir: Optional[str] = None):
|
|
51
|
+
if log_dir:
|
|
52
|
+
self.log_dir = Path(log_dir)
|
|
53
|
+
else:
|
|
54
|
+
# Find the correct .claude directory
|
|
55
|
+
claude_dir = find_claude_dir()
|
|
56
|
+
self.log_dir = claude_dir / "logs"
|
|
57
|
+
self.log_dir.mkdir(exist_ok=True, parents=True)
|
|
58
|
+
self.session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
|
|
59
|
+
|
|
60
|
+
def hash_output(self, output: str, max_length: int = 1000) -> str:
|
|
61
|
+
"""Create hash of output for audit trail"""
|
|
62
|
+
# Truncate output for hashing
|
|
63
|
+
truncated = output[:max_length] if len(output) > max_length else output
|
|
64
|
+
return hashlib.sha256(truncated.encode()).hexdigest()[:16]
|
|
65
|
+
|
|
66
|
+
def log_execution(self, tool_name: str, parameters: Dict,
|
|
67
|
+
result: Any, duration: float, exit_code: int = 0):
|
|
68
|
+
"""Log tool execution details"""
|
|
69
|
+
|
|
70
|
+
timestamp = datetime.now().isoformat()
|
|
71
|
+
|
|
72
|
+
# Extract command for bash tools
|
|
73
|
+
command = ""
|
|
74
|
+
if tool_name.lower() == "bash":
|
|
75
|
+
command = parameters.get("command", "")
|
|
76
|
+
|
|
77
|
+
# Process result
|
|
78
|
+
output_preview = ""
|
|
79
|
+
output_hash = ""
|
|
80
|
+
if result:
|
|
81
|
+
result_str = str(result)
|
|
82
|
+
output_preview = result_str[:200] + "..." if len(result_str) > 200 else result_str
|
|
83
|
+
output_hash = self.hash_output(result_str)
|
|
84
|
+
|
|
85
|
+
# Create audit record
|
|
86
|
+
audit_record = {
|
|
87
|
+
"timestamp": timestamp,
|
|
88
|
+
"session_id": self.session_id,
|
|
89
|
+
"tool_name": tool_name,
|
|
90
|
+
"command": command,
|
|
91
|
+
"parameters": parameters,
|
|
92
|
+
"duration_ms": round(duration * 1000, 2),
|
|
93
|
+
"exit_code": exit_code,
|
|
94
|
+
"output_hash": output_hash,
|
|
95
|
+
"output_preview": output_preview
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Write to session log
|
|
99
|
+
session_log_file = self.log_dir / f"session-{self.session_id}.jsonl"
|
|
100
|
+
with open(session_log_file, "a") as f:
|
|
101
|
+
f.write(json.dumps(audit_record) + "\n")
|
|
102
|
+
|
|
103
|
+
# Write to daily audit log
|
|
104
|
+
daily_log_file = self.log_dir / f"audit-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
|
|
105
|
+
with open(daily_log_file, "a") as f:
|
|
106
|
+
f.write(json.dumps(audit_record) + "\n")
|
|
107
|
+
|
|
108
|
+
logger.info(f"Logged execution: {tool_name} - {command[:50]} - {duration:.2f}s")
|
|
109
|
+
|
|
110
|
+
class MetricsCollector:
|
|
111
|
+
"""Collect and aggregate execution metrics"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, metrics_dir: Optional[str] = None):
|
|
114
|
+
if metrics_dir:
|
|
115
|
+
self.metrics_dir = Path(metrics_dir)
|
|
116
|
+
else:
|
|
117
|
+
# Find the correct .claude directory
|
|
118
|
+
claude_dir = find_claude_dir()
|
|
119
|
+
self.metrics_dir = claude_dir / "metrics"
|
|
120
|
+
self.metrics_dir.mkdir(exist_ok=True, parents=True)
|
|
121
|
+
|
|
122
|
+
def record_execution(self, tool_name: str, command: str, duration: float,
|
|
123
|
+
success: bool, tier: str = "unknown"):
|
|
124
|
+
"""Record execution metrics"""
|
|
125
|
+
|
|
126
|
+
timestamp = datetime.now().isoformat()
|
|
127
|
+
|
|
128
|
+
metrics_record = {
|
|
129
|
+
"timestamp": timestamp,
|
|
130
|
+
"tool_name": tool_name,
|
|
131
|
+
"command_type": self._classify_command(command),
|
|
132
|
+
"duration_ms": round(duration * 1000, 2),
|
|
133
|
+
"success": success,
|
|
134
|
+
"tier": tier
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Write to metrics file
|
|
138
|
+
metrics_file = self.metrics_dir / f"metrics-{datetime.now().strftime('%Y-%m')}.jsonl"
|
|
139
|
+
with open(metrics_file, "a") as f:
|
|
140
|
+
f.write(json.dumps(metrics_record) + "\n")
|
|
141
|
+
|
|
142
|
+
def _classify_command(self, command: str) -> str:
|
|
143
|
+
"""Classify command type for metrics"""
|
|
144
|
+
|
|
145
|
+
if "terraform" in command.lower():
|
|
146
|
+
return "terraform"
|
|
147
|
+
elif "kubectl" in command.lower():
|
|
148
|
+
return "kubernetes"
|
|
149
|
+
elif "helm" in command.lower():
|
|
150
|
+
return "helm"
|
|
151
|
+
elif "gcloud" in command.lower():
|
|
152
|
+
return "gcp"
|
|
153
|
+
elif "aws" in command.lower():
|
|
154
|
+
return "aws"
|
|
155
|
+
elif "flux" in command.lower():
|
|
156
|
+
return "flux"
|
|
157
|
+
elif "docker" in command.lower():
|
|
158
|
+
return "docker"
|
|
159
|
+
else:
|
|
160
|
+
return "general"
|
|
161
|
+
|
|
162
|
+
def generate_summary(self, days: int = 7) -> Dict[str, Any]:
|
|
163
|
+
"""Generate metrics summary for the last N days"""
|
|
164
|
+
|
|
165
|
+
# This would typically read from metrics files and aggregate
|
|
166
|
+
# For now, return a placeholder summary
|
|
167
|
+
return {
|
|
168
|
+
"period_days": days,
|
|
169
|
+
"total_executions": 0,
|
|
170
|
+
"success_rate": 0.0,
|
|
171
|
+
"avg_duration_ms": 0.0,
|
|
172
|
+
"top_commands": [],
|
|
173
|
+
"tier_distribution": {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class NotificationHandler:
|
|
177
|
+
"""Handle notifications for threshold breaches or important events"""
|
|
178
|
+
|
|
179
|
+
def __init__(self):
|
|
180
|
+
self.thresholds = {
|
|
181
|
+
"long_execution_seconds": 60,
|
|
182
|
+
"high_failure_rate": 0.3,
|
|
183
|
+
"blocked_command_count": 5
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def check_thresholds(self, duration: float, success: bool, tool_name: str):
|
|
187
|
+
"""Check if execution crosses any notification thresholds"""
|
|
188
|
+
|
|
189
|
+
notifications = []
|
|
190
|
+
|
|
191
|
+
# Long execution time
|
|
192
|
+
if duration > self.thresholds["long_execution_seconds"]:
|
|
193
|
+
notifications.append({
|
|
194
|
+
"type": "long_execution",
|
|
195
|
+
"message": f"Long execution detected: {tool_name} took {duration:.1f}s",
|
|
196
|
+
"severity": "warning"
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
# Command failure
|
|
200
|
+
if not success:
|
|
201
|
+
notifications.append({
|
|
202
|
+
"type": "command_failure",
|
|
203
|
+
"message": f"Command failed: {tool_name}",
|
|
204
|
+
"severity": "error"
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return notifications
|
|
208
|
+
|
|
209
|
+
class CriticalEventDetector:
|
|
210
|
+
"""Detect critical events that warrant context updates"""
|
|
211
|
+
|
|
212
|
+
# Track file modifications within session
|
|
213
|
+
file_modification_count = 0
|
|
214
|
+
file_modification_threshold = 3
|
|
215
|
+
|
|
216
|
+
def __init__(self):
|
|
217
|
+
self.speckit_commands = [
|
|
218
|
+
"/speckit.specify", "/speckit.plan", "/speckit.tasks",
|
|
219
|
+
"/speckit.implement", "/speckit.constitution"
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def is_git_commit(self, tool_name: str, parameters: Dict, result: Any, success: bool) -> Optional[Dict]:
|
|
223
|
+
"""Detect successful git commit"""
|
|
224
|
+
if not success:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
if tool_name.lower() == "bash":
|
|
228
|
+
command = parameters.get("command", "")
|
|
229
|
+
if "git commit" in command and result:
|
|
230
|
+
# Extract commit metadata
|
|
231
|
+
result_str = str(result)
|
|
232
|
+
|
|
233
|
+
# Try to extract commit hash (format: [branch hash] message)
|
|
234
|
+
commit_hash = ""
|
|
235
|
+
commit_message = ""
|
|
236
|
+
|
|
237
|
+
# Look for pattern like "[main 1a2b3c4]"
|
|
238
|
+
import re
|
|
239
|
+
match = re.search(r'\[[\w\-/]+ ([a-f0-9]{7,})\]', result_str)
|
|
240
|
+
if match:
|
|
241
|
+
commit_hash = match.group(1)
|
|
242
|
+
|
|
243
|
+
# Extract message (usually after the hash pattern)
|
|
244
|
+
if commit_hash:
|
|
245
|
+
msg_match = re.search(r'\[[\w\-/]+ [a-f0-9]{7,}\]\s*(.+)', result_str, re.MULTILINE)
|
|
246
|
+
if msg_match:
|
|
247
|
+
commit_message = msg_match.group(1).strip().split('\n')[0]
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"event_type": "git_commit",
|
|
251
|
+
"commit_hash": commit_hash,
|
|
252
|
+
"commit_message": commit_message,
|
|
253
|
+
"command": command
|
|
254
|
+
}
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def is_git_push(self, tool_name: str, parameters: Dict, result: Any, success: bool) -> Optional[Dict]:
|
|
258
|
+
"""Detect successful git push"""
|
|
259
|
+
if not success:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
if tool_name.lower() == "bash":
|
|
263
|
+
command = parameters.get("command", "")
|
|
264
|
+
if "git push" in command and result:
|
|
265
|
+
result_str = str(result)
|
|
266
|
+
|
|
267
|
+
# Extract branch info
|
|
268
|
+
branch = ""
|
|
269
|
+
import re
|
|
270
|
+
match = re.search(r'To .+\n\s+[a-f0-9]+\.\.[a-f0-9]+\s+([\w\-/]+)\s+->', result_str)
|
|
271
|
+
if match:
|
|
272
|
+
branch = match.group(1)
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"event_type": "git_push",
|
|
276
|
+
"branch": branch,
|
|
277
|
+
"command": command
|
|
278
|
+
}
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def should_update_for_file_mods(self, tool_name: str) -> Optional[Dict]:
|
|
282
|
+
"""Check if file modification count crosses threshold"""
|
|
283
|
+
if tool_name.lower() in ["edit", "write", "notebookedit"]:
|
|
284
|
+
CriticalEventDetector.file_modification_count += 1
|
|
285
|
+
|
|
286
|
+
if CriticalEventDetector.file_modification_count >= self.file_modification_threshold:
|
|
287
|
+
# Reset counter
|
|
288
|
+
count = CriticalEventDetector.file_modification_count
|
|
289
|
+
CriticalEventDetector.file_modification_count = 0
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"event_type": "file_modifications",
|
|
293
|
+
"modification_count": count
|
|
294
|
+
}
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def is_speckit_milestone(self, tool_name: str, parameters: Dict) -> Optional[Dict]:
|
|
298
|
+
"""Detect spec-kit milestone commands"""
|
|
299
|
+
if tool_name.lower() == "slashcommand":
|
|
300
|
+
command = parameters.get("command", "")
|
|
301
|
+
for speckit_cmd in self.speckit_commands:
|
|
302
|
+
if speckit_cmd in command:
|
|
303
|
+
return {
|
|
304
|
+
"event_type": "speckit_milestone",
|
|
305
|
+
"command": speckit_cmd
|
|
306
|
+
}
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
class ActiveContextUpdater:
|
|
310
|
+
"""Update active session context for critical events"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, context_path: Optional[str] = None):
|
|
313
|
+
if context_path:
|
|
314
|
+
self.context_path = Path(context_path)
|
|
315
|
+
else:
|
|
316
|
+
# Find the correct .claude directory
|
|
317
|
+
claude_dir = find_claude_dir()
|
|
318
|
+
self.context_path = claude_dir / "session" / "active" / "context.json"
|
|
319
|
+
self.context_path.parent.mkdir(exist_ok=True, parents=True)
|
|
320
|
+
|
|
321
|
+
def update_context(self, event_data: Dict) -> None:
|
|
322
|
+
"""Update active context with event data"""
|
|
323
|
+
try:
|
|
324
|
+
# Load existing context
|
|
325
|
+
context = {}
|
|
326
|
+
if self.context_path.exists():
|
|
327
|
+
with open(self.context_path, 'r') as f:
|
|
328
|
+
context = json.load(f)
|
|
329
|
+
|
|
330
|
+
# Initialize events list if not exists
|
|
331
|
+
if "critical_events" not in context:
|
|
332
|
+
context["critical_events"] = []
|
|
333
|
+
|
|
334
|
+
# Add timestamp to event
|
|
335
|
+
event_data["timestamp"] = datetime.now().isoformat()
|
|
336
|
+
|
|
337
|
+
# Append new event
|
|
338
|
+
context["critical_events"].append(event_data)
|
|
339
|
+
|
|
340
|
+
# Keep only last 20 events (prevent unbounded growth)
|
|
341
|
+
context["critical_events"] = context["critical_events"][-20:]
|
|
342
|
+
|
|
343
|
+
# Update last_modified
|
|
344
|
+
context["last_modified"] = datetime.now().isoformat()
|
|
345
|
+
|
|
346
|
+
# Write back to file
|
|
347
|
+
with open(self.context_path, 'w') as f:
|
|
348
|
+
json.dump(context, f, indent=2)
|
|
349
|
+
|
|
350
|
+
logger.info(f"Updated active context with event: {event_data['event_type']}")
|
|
351
|
+
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f"Error updating active context: {e}")
|
|
354
|
+
|
|
355
|
+
def post_tool_use_hook(tool_name: str, parameters: Dict, result: Any,
|
|
356
|
+
duration: float, success: bool = True) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Post-tool use hook implementation
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
tool_name: Name of the tool that was invoked
|
|
362
|
+
parameters: Tool parameters
|
|
363
|
+
result: Tool execution result
|
|
364
|
+
duration: Execution duration in seconds
|
|
365
|
+
success: Whether execution was successful
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
# Initialize components
|
|
370
|
+
audit_logger = AuditLogger()
|
|
371
|
+
metrics_collector = MetricsCollector()
|
|
372
|
+
notification_handler = NotificationHandler()
|
|
373
|
+
|
|
374
|
+
# Determine exit code
|
|
375
|
+
exit_code = 0 if success else 1
|
|
376
|
+
|
|
377
|
+
# Log execution
|
|
378
|
+
audit_logger.log_execution(tool_name, parameters, result, duration, exit_code)
|
|
379
|
+
|
|
380
|
+
# Extract command for metrics
|
|
381
|
+
command = parameters.get("command", "") if tool_name.lower() == "bash" else tool_name
|
|
382
|
+
|
|
383
|
+
# Determine tier (this would ideally come from pre_tool_use)
|
|
384
|
+
tier = "unknown" # Could be enhanced to track tier from pre-hook
|
|
385
|
+
|
|
386
|
+
# Record metrics
|
|
387
|
+
metrics_collector.record_execution(tool_name, command, duration, success, tier)
|
|
388
|
+
|
|
389
|
+
# Check for notifications
|
|
390
|
+
notifications = notification_handler.check_thresholds(duration, success, tool_name)
|
|
391
|
+
|
|
392
|
+
for notification in notifications:
|
|
393
|
+
logger.warning(f"NOTIFICATION: {notification['message']}")
|
|
394
|
+
|
|
395
|
+
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
396
|
+
# CRITICAL EVENT DETECTION & CONTEXT UPDATES
|
|
397
|
+
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
398
|
+
|
|
399
|
+
event_detector = CriticalEventDetector()
|
|
400
|
+
context_updater = ActiveContextUpdater()
|
|
401
|
+
|
|
402
|
+
# Detect git commit
|
|
403
|
+
git_commit_event = event_detector.is_git_commit(tool_name, parameters, result, success)
|
|
404
|
+
if git_commit_event:
|
|
405
|
+
context_updater.update_context(git_commit_event)
|
|
406
|
+
|
|
407
|
+
# Detect git push
|
|
408
|
+
git_push_event = event_detector.is_git_push(tool_name, parameters, result, success)
|
|
409
|
+
if git_push_event:
|
|
410
|
+
context_updater.update_context(git_push_event)
|
|
411
|
+
|
|
412
|
+
# Detect file modifications batch
|
|
413
|
+
file_mod_event = event_detector.should_update_for_file_mods(tool_name)
|
|
414
|
+
if file_mod_event:
|
|
415
|
+
context_updater.update_context(file_mod_event)
|
|
416
|
+
|
|
417
|
+
# Detect spec-kit milestones
|
|
418
|
+
speckit_event = event_detector.is_speckit_milestone(tool_name, parameters)
|
|
419
|
+
if speckit_event:
|
|
420
|
+
context_updater.update_context(speckit_event)
|
|
421
|
+
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.error(f"Error in post_tool_use_hook: {e}")
|
|
424
|
+
|
|
425
|
+
def main():
|
|
426
|
+
"""CLI interface for testing and metrics"""
|
|
427
|
+
|
|
428
|
+
if len(sys.argv) < 2:
|
|
429
|
+
print("Usage: python post_tool_use.py --metrics")
|
|
430
|
+
print(" python post_tool_use.py --test")
|
|
431
|
+
sys.exit(1)
|
|
432
|
+
|
|
433
|
+
if sys.argv[1] == "--metrics":
|
|
434
|
+
# Show current metrics
|
|
435
|
+
metrics_collector = MetricsCollector()
|
|
436
|
+
summary = metrics_collector.generate_summary()
|
|
437
|
+
|
|
438
|
+
print("š Execution Metrics Summary")
|
|
439
|
+
print(f"Period: {summary['period_days']} days")
|
|
440
|
+
print(f"Total executions: {summary['total_executions']}")
|
|
441
|
+
print(f"Success rate: {summary['success_rate']:.1%}")
|
|
442
|
+
print(f"Average duration: {summary['avg_duration_ms']:.1f}ms")
|
|
443
|
+
|
|
444
|
+
elif sys.argv[1] == "--test":
|
|
445
|
+
# Test the post hook
|
|
446
|
+
print("š§Ŗ Testing Post-Tool Use Hook...")
|
|
447
|
+
|
|
448
|
+
test_parameters = {"command": "kubectl get pods"}
|
|
449
|
+
test_result = "pod/test-pod 1/1 Running 0 1m"
|
|
450
|
+
|
|
451
|
+
start_time = time.time()
|
|
452
|
+
post_tool_use_hook("bash", test_parameters, test_result, 0.5, True)
|
|
453
|
+
|
|
454
|
+
print("ā
Post-hook test completed")
|
|
455
|
+
print("Check .claude/logs/ for audit logs")
|
|
456
|
+
print("Check .claude/metrics/ for metrics")
|
|
457
|
+
|
|
458
|
+
else:
|
|
459
|
+
print(f"Unknown command: {sys.argv[1]}")
|
|
460
|
+
sys.exit(1)
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
main()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pre-execution security hook for kubectl commands to enforce GitOps principles.
|
|
4
|
+
Prevents direct cluster modifications and ensures proper GitOps workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
import json
|
|
10
|
+
from typing import List, Dict, Any
|
|
11
|
+
|
|
12
|
+
# Forbidden kubectl commands that modify cluster state
|
|
13
|
+
FORBIDDEN_KUBECTL_COMMANDS = [
|
|
14
|
+
r'kubectl\s+apply(?!\s+.*--dry-run)', # kubectl apply without --dry-run
|
|
15
|
+
r'kubectl\s+create(?!\s+.*--dry-run)', # kubectl create without --dry-run
|
|
16
|
+
r'kubectl\s+patch', # kubectl patch (always modifies state)
|
|
17
|
+
r'kubectl\s+replace', # kubectl replace (modifies state)
|
|
18
|
+
r'kubectl\s+delete', # kubectl delete (destructive)
|
|
19
|
+
r'kubectl\s+scale', # kubectl scale (modifies state)
|
|
20
|
+
r'kubectl\s+rollout\s+restart', # kubectl rollout restart (modifies state)
|
|
21
|
+
r'kubectl\s+annotate(?!\s+.*--dry-run)', # kubectl annotate without --dry-run
|
|
22
|
+
r'kubectl\s+label(?!\s+.*--dry-run)', # kubectl label without --dry-run
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Forbidden flux commands that trigger reconciliation
|
|
26
|
+
FORBIDDEN_FLUX_COMMANDS = [
|
|
27
|
+
r'flux\s+create', # flux create (modifies GitOps resources)
|
|
28
|
+
r'flux\s+delete', # flux delete (destructive)
|
|
29
|
+
r'flux\s+suspend', # flux suspend (modifies reconciliation)
|
|
30
|
+
r'flux\s+resume', # flux resume (modifies reconciliation)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Forbidden helm commands that modify releases
|
|
34
|
+
FORBIDDEN_HELM_COMMANDS = [
|
|
35
|
+
r'helm\s+install(?!\s+.*--dry-run)', # helm install without --dry-run
|
|
36
|
+
r'helm\s+upgrade(?!\s+.*--dry-run)', # helm upgrade without --dry-run
|
|
37
|
+
r'helm\s+uninstall', # helm uninstall (destructive)
|
|
38
|
+
r'helm\s+rollback', # helm rollback (modifies state)
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Safe read-only commands that are always allowed
|
|
42
|
+
SAFE_KUBECTL_COMMANDS = [
|
|
43
|
+
r'kubectl\s+get',
|
|
44
|
+
r'kubectl\s+describe',
|
|
45
|
+
r'kubectl\s+logs',
|
|
46
|
+
r'kubectl\s+top',
|
|
47
|
+
r'kubectl\s+explain',
|
|
48
|
+
r'kubectl\s+version',
|
|
49
|
+
r'kubectl\s+cluster-info',
|
|
50
|
+
r'kubectl\s+config\s+view',
|
|
51
|
+
r'kubectl\s+api-resources',
|
|
52
|
+
r'kubectl\s+api-versions',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
SAFE_FLUX_COMMANDS = [
|
|
56
|
+
r'flux\s+get',
|
|
57
|
+
r'flux\s+check',
|
|
58
|
+
r'flux\s+version',
|
|
59
|
+
r'flux\s+logs',
|
|
60
|
+
r'flux\s+stats',
|
|
61
|
+
r'flux\s+tree',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
SAFE_HELM_COMMANDS = [
|
|
65
|
+
r'helm\s+list',
|
|
66
|
+
r'helm\s+status',
|
|
67
|
+
r'helm\s+history',
|
|
68
|
+
r'helm\s+template',
|
|
69
|
+
r'helm\s+lint',
|
|
70
|
+
r'helm\s+version',
|
|
71
|
+
r'helm\s+show',
|
|
72
|
+
r'helm\s+search',
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
class GitOpsSecurityViolation(Exception):
|
|
76
|
+
"""Exception raised when a command violates GitOps security principles."""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def is_safe_command(command: str) -> bool:
|
|
80
|
+
"""Check if a command is explicitly safe (read-only)."""
|
|
81
|
+
safe_patterns = SAFE_KUBECTL_COMMANDS + SAFE_FLUX_COMMANDS + SAFE_HELM_COMMANDS
|
|
82
|
+
|
|
83
|
+
for pattern in safe_patterns:
|
|
84
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def is_forbidden_command(command: str) -> bool:
|
|
89
|
+
"""Check if a command is forbidden (modifies cluster state)."""
|
|
90
|
+
forbidden_patterns = FORBIDDEN_KUBECTL_COMMANDS + FORBIDDEN_FLUX_COMMANDS + FORBIDDEN_HELM_COMMANDS
|
|
91
|
+
|
|
92
|
+
for pattern in forbidden_patterns:
|
|
93
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def validate_gitops_workflow(command: str, agent_type: str = None) -> Dict[str, Any]:
|
|
98
|
+
"""
|
|
99
|
+
Validate command against GitOps security principles.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
dict: Validation result with status and details
|
|
103
|
+
"""
|
|
104
|
+
result = {
|
|
105
|
+
"allowed": False,
|
|
106
|
+
"reason": "",
|
|
107
|
+
"suggestions": [],
|
|
108
|
+
"severity": "info"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Check if command is explicitly safe
|
|
112
|
+
if is_safe_command(command):
|
|
113
|
+
result["allowed"] = True
|
|
114
|
+
result["reason"] = "Read-only operation - safe to execute"
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
# Check if command is forbidden
|
|
118
|
+
if is_forbidden_command(command):
|
|
119
|
+
result["allowed"] = False
|
|
120
|
+
result["severity"] = "critical"
|
|
121
|
+
result["reason"] = "Command violates GitOps principles - modifies cluster state directly"
|
|
122
|
+
|
|
123
|
+
# Provide specific suggestions based on command type
|
|
124
|
+
if "kubectl apply" in command and "--dry-run" not in command:
|
|
125
|
+
result["suggestions"].extend([
|
|
126
|
+
"Use: kubectl apply --dry-run=client -f <file>",
|
|
127
|
+
"Create manifests in gitops repository first",
|
|
128
|
+
"Commit changes and let Flux CD reconcile"
|
|
129
|
+
])
|
|
130
|
+
elif "flux reconcile" in command and "--dry-run" not in command:
|
|
131
|
+
result["suggestions"].extend([
|
|
132
|
+
"Use: flux reconcile <resource> --dry-run",
|
|
133
|
+
"Follow GitOps workflow: commit ā push ā automatic reconciliation"
|
|
134
|
+
])
|
|
135
|
+
elif "helm install" in command or "helm upgrade" in command:
|
|
136
|
+
result["suggestions"].extend([
|
|
137
|
+
"Use: helm template or helm upgrade --dry-run",
|
|
138
|
+
"Deploy via HelmRelease manifests in gitops repository"
|
|
139
|
+
])
|
|
140
|
+
else:
|
|
141
|
+
result["suggestions"].append("Use read-only commands or --dry-run alternatives")
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
# For gitops-operator agent, be extra strict
|
|
146
|
+
if agent_type == "gitops-operator":
|
|
147
|
+
# Even if not explicitly forbidden, require explicit dry-run for apply operations
|
|
148
|
+
if ("apply" in command or "create" in command) and "--dry-run" not in command:
|
|
149
|
+
result["allowed"] = False
|
|
150
|
+
result["severity"] = "high"
|
|
151
|
+
result["reason"] = "GitOps operator must use --dry-run for all apply operations"
|
|
152
|
+
result["suggestions"].append("Add --dry-run=client flag to command")
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
# Default: allow but warn about unclear intent
|
|
156
|
+
result["allowed"] = True
|
|
157
|
+
result["severity"] = "warning"
|
|
158
|
+
result["reason"] = "Command not explicitly validated - proceed with caution"
|
|
159
|
+
result["suggestions"].append("Verify command follows GitOps principles")
|
|
160
|
+
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
def main():
|
|
164
|
+
"""Main hook execution."""
|
|
165
|
+
if len(sys.argv) < 2:
|
|
166
|
+
print("Usage: pre_kubectl_security.py <command>")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
command = " ".join(sys.argv[1:])
|
|
170
|
+
|
|
171
|
+
# Extract agent type from environment or command context if available
|
|
172
|
+
agent_type = None
|
|
173
|
+
if "gitops-operator" in command or "GITOPS_OPERATOR" in str(sys.argv):
|
|
174
|
+
agent_type = "gitops-operator"
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
validation = validate_gitops_workflow(command, agent_type)
|
|
178
|
+
|
|
179
|
+
if not validation["allowed"]:
|
|
180
|
+
print(f"šØ SECURITY VIOLATION: {validation['reason']}")
|
|
181
|
+
print(f"š Command: {command}")
|
|
182
|
+
print(f"ā ļø Severity: {validation['severity'].upper()}")
|
|
183
|
+
|
|
184
|
+
if validation["suggestions"]:
|
|
185
|
+
print("š” Suggestions:")
|
|
186
|
+
for suggestion in validation["suggestions"]:
|
|
187
|
+
print(f" ⢠{suggestion}")
|
|
188
|
+
|
|
189
|
+
print("\nš GitOps Security Enforcement Active")
|
|
190
|
+
print("š Review: /home/jaguilar/aaxis/rnd/repositories/.claude/agents/gitops-operator.md")
|
|
191
|
+
|
|
192
|
+
sys.exit(1) # Block command execution
|
|
193
|
+
|
|
194
|
+
elif validation["severity"] in ["warning", "high"]:
|
|
195
|
+
print(f"ā ļø WARNING: {validation['reason']}")
|
|
196
|
+
if validation["suggestions"]:
|
|
197
|
+
for suggestion in validation["suggestions"]:
|
|
198
|
+
print(f"š” {suggestion}")
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"ā Hook execution error: {e}")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|