@trac3er/oh-my-god 2.0.0 → 2.0.2
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 +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Fetch Claude rate limits from Anthropic OAuth API and cache for HUD.
|
|
4
|
+
|
|
5
|
+
Reads OAuth credentials from:
|
|
6
|
+
- macOS: Keychain "Claude Code-credentials" (format: {claudeAiOauth: {...}})
|
|
7
|
+
- Linux/fallback: ~/.claude/.credentials.json
|
|
8
|
+
|
|
9
|
+
Caches to: ~/.claude/omg-runtime/.usage-cache.json
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import ssl
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_claude_config_dir():
|
|
24
|
+
"""Get Claude config directory."""
|
|
25
|
+
return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_cache_path():
|
|
29
|
+
"""Get cache file path."""
|
|
30
|
+
return get_claude_config_dir() / "omg-runtime" / ".usage-cache.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_credentials_from_keychain():
|
|
34
|
+
"""Read OAuth credentials from macOS Keychain."""
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=5
|
|
41
|
+
)
|
|
42
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
43
|
+
data = json.loads(result.stdout.strip())
|
|
44
|
+
# Claude Code stores credentials under 'claudeAiOauth' key
|
|
45
|
+
if "claudeAiOauth" in data:
|
|
46
|
+
return data["claudeAiOauth"]
|
|
47
|
+
return data
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_credentials_from_file():
|
|
54
|
+
"""Read OAuth credentials from file."""
|
|
55
|
+
creds_path = get_claude_config_dir() / ".credentials.json"
|
|
56
|
+
try:
|
|
57
|
+
if creds_path.exists():
|
|
58
|
+
data = json.loads(creds_path.read_text())
|
|
59
|
+
# Handle nested format if present
|
|
60
|
+
if "claudeAiOauth" in data:
|
|
61
|
+
return data["claudeAiOauth"]
|
|
62
|
+
return data
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_credentials():
|
|
69
|
+
"""Read OAuth credentials from keychain or file."""
|
|
70
|
+
# Try keychain first (macOS)
|
|
71
|
+
creds = read_credentials_from_keychain()
|
|
72
|
+
if creds and creds.get("accessToken"):
|
|
73
|
+
return creds
|
|
74
|
+
|
|
75
|
+
# Fall back to file
|
|
76
|
+
creds = read_credentials_from_file()
|
|
77
|
+
if creds and creds.get("accessToken"):
|
|
78
|
+
return creds
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch_usage(credentials):
|
|
84
|
+
"""Fetch usage from Anthropic API."""
|
|
85
|
+
access_token = credentials.get("accessToken")
|
|
86
|
+
if not access_token:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Create HTTPS request
|
|
90
|
+
ctx = ssl.create_default_context()
|
|
91
|
+
req = urllib.request.Request(
|
|
92
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
93
|
+
headers={
|
|
94
|
+
"Authorization": f"Bearer {access_token}",
|
|
95
|
+
"anthropic-beta": "oauth-2025-04-20", # Required for OAuth API access
|
|
96
|
+
"Accept": "application/json"
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
|
|
102
|
+
data = json.loads(resp.read().decode())
|
|
103
|
+
|
|
104
|
+
# Parse response into RateLimits format
|
|
105
|
+
rate_limits = {}
|
|
106
|
+
|
|
107
|
+
# Five hour (session) limit - API returns percentage 0-100 directly
|
|
108
|
+
five_hour = data.get("five_hour", {})
|
|
109
|
+
if five_hour and "utilization" in five_hour:
|
|
110
|
+
rate_limits["fiveHourPercent"] = float(five_hour["utilization"])
|
|
111
|
+
if five_hour.get("resets_at"):
|
|
112
|
+
rate_limits["fiveHourResetsAt"] = five_hour["resets_at"]
|
|
113
|
+
|
|
114
|
+
# Seven day (weekly) limit - API returns percentage 0-100 directly
|
|
115
|
+
seven_day = data.get("seven_day", {})
|
|
116
|
+
if seven_day and "utilization" in seven_day:
|
|
117
|
+
rate_limits["weeklyPercent"] = float(seven_day["utilization"])
|
|
118
|
+
if seven_day.get("resets_at"):
|
|
119
|
+
rate_limits["weeklyResetsAt"] = seven_day["resets_at"]
|
|
120
|
+
|
|
121
|
+
# Per-model quotas
|
|
122
|
+
sonnet = data.get("seven_day_sonnet", {})
|
|
123
|
+
if sonnet and "utilization" in sonnet:
|
|
124
|
+
rate_limits["sonnetWeeklyPercent"] = float(sonnet["utilization"])
|
|
125
|
+
if sonnet.get("resets_at"):
|
|
126
|
+
rate_limits["sonnetWeeklyResetsAt"] = sonnet["resets_at"]
|
|
127
|
+
|
|
128
|
+
opus = data.get("seven_day_opus", {})
|
|
129
|
+
if opus and "utilization" in opus:
|
|
130
|
+
rate_limits["opusWeeklyPercent"] = float(opus["utilization"])
|
|
131
|
+
if opus.get("resets_at"):
|
|
132
|
+
rate_limits["opusWeeklyResetsAt"] = opus["resets_at"]
|
|
133
|
+
|
|
134
|
+
return rate_limits
|
|
135
|
+
|
|
136
|
+
except urllib.error.HTTPError as e:
|
|
137
|
+
if e.code == 401:
|
|
138
|
+
# Token expired or invalid
|
|
139
|
+
pass
|
|
140
|
+
return None
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_cache(rate_limits):
|
|
146
|
+
"""Write rate limits to cache file."""
|
|
147
|
+
cache_path = get_cache_path()
|
|
148
|
+
cache_dir = cache_path.parent
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
cache_data = {
|
|
154
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
155
|
+
"data": rate_limits,
|
|
156
|
+
"source": "anthropic"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Write to temp file then rename for atomicity
|
|
160
|
+
temp_path = cache_path.with_suffix(".tmp")
|
|
161
|
+
temp_path.write_text(json.dumps(cache_data, indent=2))
|
|
162
|
+
temp_path.rename(cache_path)
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
except Exception:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_existing_cache():
|
|
170
|
+
"""Read existing cache if present."""
|
|
171
|
+
cache_path = get_cache_path()
|
|
172
|
+
try:
|
|
173
|
+
if cache_path.exists():
|
|
174
|
+
return json.loads(cache_path.read_text())
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
"""Main entry point."""
|
|
182
|
+
# Check if cache is fresh (less than 30 seconds old)
|
|
183
|
+
existing = read_existing_cache()
|
|
184
|
+
if existing:
|
|
185
|
+
try:
|
|
186
|
+
cached_time = datetime.fromisoformat(existing.get("timestamp", ""))
|
|
187
|
+
age = (datetime.now(timezone.utc) - cached_time).total_seconds()
|
|
188
|
+
if age < 30:
|
|
189
|
+
# Cache is fresh, nothing to do
|
|
190
|
+
sys.exit(0)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
# Read credentials
|
|
195
|
+
credentials = read_credentials()
|
|
196
|
+
if not credentials:
|
|
197
|
+
sys.exit(0) # Silent exit if no credentials
|
|
198
|
+
|
|
199
|
+
# Fetch usage
|
|
200
|
+
rate_limits = fetch_usage(credentials)
|
|
201
|
+
if not rate_limits:
|
|
202
|
+
sys.exit(0) # Silent exit on API error
|
|
203
|
+
|
|
204
|
+
# Write cache
|
|
205
|
+
if write_cache(rate_limits):
|
|
206
|
+
print(f"[OMG] Rate limits updated: daily={rate_limits.get('fiveHourPercent', 'N/A')}%, weekly={rate_limits.get('weeklyPercent', 'N/A')}%")
|
|
207
|
+
|
|
208
|
+
sys.exit(0)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse Hook (Bash): Command Firewall (Enterprise)
|
|
3
|
+
|
|
4
|
+
Delegates policy logic to policy_engine.py so all command decisions are driven by
|
|
5
|
+
one centralized decision model.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
12
|
+
if HOOKS_DIR not in sys.path:
|
|
13
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
14
|
+
|
|
15
|
+
from _common import setup_crash_handler, json_input, deny_decision, is_bypass_mode
|
|
16
|
+
|
|
17
|
+
# Fail-closed: deny on crash (security hook)
|
|
18
|
+
setup_crash_handler("firewall", fail_closed=True)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from policy_engine import evaluate_bash_command, to_pretool_hook_output
|
|
22
|
+
except Exception as _import_err:
|
|
23
|
+
print(f"OMG firewall: policy_engine import failed: {_import_err}", file=sys.stderr)
|
|
24
|
+
deny_decision(f"OMG firewall crash: policy_engine import failed: {_import_err}. Denying for safety.")
|
|
25
|
+
sys.exit(0)
|
|
26
|
+
|
|
27
|
+
data = json_input()
|
|
28
|
+
|
|
29
|
+
tool = data.get("tool_name", "")
|
|
30
|
+
if tool != "Bash":
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
|
|
33
|
+
cmd = data.get("tool_input", {}).get("command", "")
|
|
34
|
+
if not cmd:
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
decision = evaluate_bash_command(cmd)
|
|
38
|
+
|
|
39
|
+
# In bypass-permission mode, only enforce hard denials (critical safety).
|
|
40
|
+
# Skip "ask" decisions so the user is not prompted for confirmation.
|
|
41
|
+
if is_bypass_mode(data) and decision.action != "deny":
|
|
42
|
+
sys.exit(0)
|
|
43
|
+
|
|
44
|
+
out = to_pretool_hook_output(decision)
|
|
45
|
+
if out:
|
|
46
|
+
json.dump(out, sys.stdout)
|
|
47
|
+
|
|
48
|
+
sys.exit(0)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hashline Formatter Bridge — reconciles sidecar hash cache after formatters run.
|
|
3
|
+
|
|
4
|
+
When a post-write formatter (e.g. prettier, ruff format) modifies a file,
|
|
5
|
+
the cached line hashes become stale. This bridge detects the change and
|
|
6
|
+
refreshes the sidecar cache so subsequent reads/edits use correct anchors.
|
|
7
|
+
|
|
8
|
+
Feature flag: OMG_HASHLINE_ENABLED (default: False)
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
HOOKS_DIR = os.path.dirname(__file__)
|
|
15
|
+
if HOOKS_DIR not in sys.path:
|
|
16
|
+
sys.path.insert(0, HOOKS_DIR)
|
|
17
|
+
|
|
18
|
+
from _common import (
|
|
19
|
+
setup_crash_handler,
|
|
20
|
+
json_input,
|
|
21
|
+
get_feature_flag,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
setup_crash_handler("hashline-formatter-bridge")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- Feature Flag ---
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_enabled() -> bool:
|
|
31
|
+
"""Check if hashline features are enabled.
|
|
32
|
+
|
|
33
|
+
Resolution order: OMG_HASHLINE_ENABLED env var -> settings.json -> False
|
|
34
|
+
"""
|
|
35
|
+
env_val = os.environ.get("OMG_HASHLINE_ENABLED", "").lower()
|
|
36
|
+
if env_val in ("1", "true", "yes"):
|
|
37
|
+
return True
|
|
38
|
+
if env_val in ("0", "false", "no"):
|
|
39
|
+
return False
|
|
40
|
+
return get_feature_flag("HASHLINE", default=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- Lazy Imports from hashline-injector ---
|
|
44
|
+
|
|
45
|
+
_injector = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_injector():
|
|
49
|
+
"""Lazy-load hashline-injector module."""
|
|
50
|
+
global _injector
|
|
51
|
+
if _injector is None:
|
|
52
|
+
import importlib
|
|
53
|
+
_injector = importlib.import_module("hashline-injector")
|
|
54
|
+
return _injector
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- Core Functions ---
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def detect_formatter_change(file_path: str, original_content: str, formatted_content: str) -> bool:
|
|
61
|
+
"""Return True if the formatter changed the content.
|
|
62
|
+
|
|
63
|
+
Compares stripped versions of each line to ignore trivial
|
|
64
|
+
trailing-whitespace-only differences while still detecting
|
|
65
|
+
real structural changes.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
file_path: Path to the file (for context, not read).
|
|
69
|
+
original_content: Content before formatting.
|
|
70
|
+
formatted_content: Content after formatting.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if formatted_content differs from original_content.
|
|
74
|
+
"""
|
|
75
|
+
if original_content == formatted_content:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# Compare stripped lines to ignore trivial whitespace diffs
|
|
79
|
+
orig_lines = [l.rstrip() for l in original_content.split("\n")]
|
|
80
|
+
fmt_lines = [l.rstrip() for l in formatted_content.split("\n")]
|
|
81
|
+
return orig_lines != fmt_lines
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def refresh_cache_after_format(file_path: str, formatted_content: str) -> bool:
|
|
85
|
+
"""Recompute and save line hashes for newly formatted content.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
file_path: Path to the formatted file.
|
|
89
|
+
formatted_content: The file content after formatting.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True on success (or when feature is disabled), False on error.
|
|
93
|
+
"""
|
|
94
|
+
if not _is_enabled():
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
injector = _get_injector()
|
|
99
|
+
_line_hash_id = injector._line_hash_id
|
|
100
|
+
_cache_hashes = injector._cache_hashes
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
lines = formatted_content.split("\n")
|
|
106
|
+
line_hashes = {}
|
|
107
|
+
for i, line in enumerate(lines, start=1):
|
|
108
|
+
line_hashes[str(i)] = _line_hash_id(line)
|
|
109
|
+
|
|
110
|
+
_cache_hashes(file_path, line_hashes)
|
|
111
|
+
return True
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reconcile_post_format(file_path: str) -> dict:
|
|
117
|
+
"""Reconcile hash cache with the current file on disk.
|
|
118
|
+
|
|
119
|
+
Reads the file, checks whether the cached mtime is stale
|
|
120
|
+
(indicating a formatter ran after the last cache write),
|
|
121
|
+
and refreshes the cache if needed.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
file_path: Path to the file to reconcile.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
dict with reconciliation result:
|
|
128
|
+
- ``{"skipped": True}`` when feature is disabled
|
|
129
|
+
- ``{"refreshed": True/False, "lines_updated": int, "file": str}``
|
|
130
|
+
"""
|
|
131
|
+
if not _is_enabled():
|
|
132
|
+
return {"skipped": True}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
injector = _get_injector()
|
|
136
|
+
_get_cached_hashes = injector._get_cached_hashes
|
|
137
|
+
_line_hash_id = injector._line_hash_id
|
|
138
|
+
_cache_hashes = injector._cache_hashes
|
|
139
|
+
except Exception:
|
|
140
|
+
return {"refreshed": False, "lines_updated": 0, "file": file_path}
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
abs_path = os.path.abspath(file_path)
|
|
144
|
+
if not os.path.exists(abs_path):
|
|
145
|
+
return {"refreshed": False, "lines_updated": 0, "file": file_path}
|
|
146
|
+
|
|
147
|
+
# Read current content from disk
|
|
148
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
149
|
+
content = f.read()
|
|
150
|
+
|
|
151
|
+
# Check if cache exists — if _get_cached_hashes returns None,
|
|
152
|
+
# cache is either missing or mtime doesn't match (formatter ran).
|
|
153
|
+
cached = _get_cached_hashes(file_path)
|
|
154
|
+
if cached is not None:
|
|
155
|
+
# Cache is still valid (mtime matches) — no refresh needed
|
|
156
|
+
return {"refreshed": False, "lines_updated": 0, "file": file_path}
|
|
157
|
+
|
|
158
|
+
# Cache is stale or missing — refresh
|
|
159
|
+
lines = content.split("\n")
|
|
160
|
+
line_hashes = {}
|
|
161
|
+
for i, line in enumerate(lines, start=1):
|
|
162
|
+
line_hashes[str(i)] = _line_hash_id(line)
|
|
163
|
+
|
|
164
|
+
_cache_hashes(file_path, line_hashes)
|
|
165
|
+
return {"refreshed": True, "lines_updated": len(lines), "file": file_path}
|
|
166
|
+
except Exception:
|
|
167
|
+
return {"refreshed": False, "lines_updated": 0, "file": file_path}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- Write-tool names that trigger reconciliation ---
|
|
171
|
+
|
|
172
|
+
_WRITE_TOOLS = frozenset({
|
|
173
|
+
"Write", "Edit", "MultiEdit",
|
|
174
|
+
"mcp__filesystem__write_file",
|
|
175
|
+
"mcp__filesystem__edit_file",
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Hook Entry Point ---
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main():
|
|
183
|
+
"""PostToolUse hook stdin/stdout entry point.
|
|
184
|
+
|
|
185
|
+
Reads JSON from stdin::
|
|
186
|
+
|
|
187
|
+
{"tool_name": "Write", "tool_input": {"file_path": "..."}}
|
|
188
|
+
|
|
189
|
+
If tool_name is a write tool and OMG_HASHLINE_ENABLED is set,
|
|
190
|
+
runs ``reconcile_post_format`` to refresh the hash cache after
|
|
191
|
+
any post-write formatter may have modified the file.
|
|
192
|
+
|
|
193
|
+
Always exits 0 — never raises.
|
|
194
|
+
"""
|
|
195
|
+
if not _is_enabled():
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
|
|
198
|
+
data = json_input()
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
sys.exit(0)
|
|
201
|
+
|
|
202
|
+
tool_name = data.get("tool_name", "")
|
|
203
|
+
if tool_name not in _WRITE_TOOLS:
|
|
204
|
+
sys.exit(0)
|
|
205
|
+
|
|
206
|
+
tool_input = data.get("tool_input", {})
|
|
207
|
+
if not isinstance(tool_input, dict):
|
|
208
|
+
sys.exit(0)
|
|
209
|
+
|
|
210
|
+
file_path = tool_input.get("file_path", tool_input.get("filePath", ""))
|
|
211
|
+
if not file_path:
|
|
212
|
+
sys.exit(0)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
result = reconcile_post_format(file_path)
|
|
216
|
+
json.dump(result, sys.stdout)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass # Graceful degradation — never crash
|
|
219
|
+
|
|
220
|
+
sys.exit(0)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|