@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.
Files changed (243) hide show
  1. package/.claude-plugin/marketplace.json +8 -8
  2. package/.claude-plugin/plugin.json +5 -4
  3. package/.claude-plugin/scripts/uninstall.sh +74 -3
  4. package/.claude-plugin/scripts/update.sh +78 -3
  5. package/.coveragerc +26 -0
  6. package/.mcp.json +4 -4
  7. package/CHANGELOG.md +14 -0
  8. package/CODE_OF_CONDUCT.md +27 -0
  9. package/CONTRIBUTING.md +62 -0
  10. package/OMG-setup.sh +1201 -355
  11. package/README.md +77 -56
  12. package/SECURITY.md +25 -0
  13. package/agents/__init__.py +1 -0
  14. package/agents/model_roles.py +196 -0
  15. package/agents/omg-architect-mode.md +3 -5
  16. package/agents/omg-backend-engineer.md +3 -5
  17. package/agents/omg-database-engineer.md +3 -5
  18. package/agents/omg-frontend-designer.md +4 -5
  19. package/agents/omg-implement-mode.md +4 -5
  20. package/agents/omg-infra-engineer.md +3 -5
  21. package/agents/omg-research-mode.md +4 -6
  22. package/agents/omg-security-auditor.md +3 -5
  23. package/agents/omg-testing-engineer.md +3 -5
  24. package/build/lib/yaml.py +321 -0
  25. package/commands/OMG:ai-commit.md +101 -14
  26. package/commands/OMG:arch.md +302 -19
  27. package/commands/OMG:ccg.md +12 -7
  28. package/commands/OMG:compat.md +25 -17
  29. package/commands/OMG:cost.md +173 -13
  30. package/commands/OMG:crazy.md +1 -1
  31. package/commands/OMG:create-agent.md +170 -20
  32. package/commands/OMG:deps.md +235 -17
  33. package/commands/OMG:domain-init.md +1 -1
  34. package/commands/OMG:escalate.md +41 -12
  35. package/commands/OMG:health-check.md +37 -13
  36. package/commands/OMG:init.md +122 -14
  37. package/commands/OMG:project-init.md +1 -1
  38. package/commands/OMG:session-branch.md +76 -9
  39. package/commands/OMG:session-fork.md +42 -5
  40. package/commands/OMG:session-merge.md +124 -8
  41. package/commands/OMG:setup.md +69 -12
  42. package/commands/OMG:stats.md +215 -14
  43. package/commands/OMG:teams.md +19 -10
  44. package/config/lsp_languages.yaml +8 -0
  45. package/hooks/__init__.py +0 -0
  46. package/hooks/_agent_registry.py +423 -0
  47. package/hooks/_analytics.py +291 -0
  48. package/hooks/_budget.py +31 -0
  49. package/hooks/_common.py +569 -0
  50. package/hooks/_compression_optimizer.py +119 -0
  51. package/hooks/_cost_ledger.py +176 -0
  52. package/hooks/_learnings.py +126 -0
  53. package/hooks/_memory.py +103 -0
  54. package/hooks/_protected_context.py +150 -0
  55. package/hooks/_token_counter.py +221 -0
  56. package/hooks/branch_manager.py +236 -0
  57. package/hooks/budget_governor.py +232 -0
  58. package/hooks/circuit-breaker.py +270 -0
  59. package/hooks/compression_feedback.py +254 -0
  60. package/hooks/config-guard.py +216 -0
  61. package/hooks/context_pressure.py +53 -0
  62. package/hooks/credential_store.py +1020 -0
  63. package/hooks/fetch-rate-limits.py +212 -0
  64. package/hooks/firewall.py +48 -0
  65. package/hooks/hashline-formatter-bridge.py +224 -0
  66. package/hooks/hashline-injector.py +273 -0
  67. package/hooks/hashline-validator.py +216 -0
  68. package/hooks/idle-detector.py +95 -0
  69. package/hooks/intentgate-keyword-detector.py +188 -0
  70. package/hooks/magic-keyword-router.py +195 -0
  71. package/hooks/policy_engine.py +505 -0
  72. package/hooks/post-tool-failure.py +19 -0
  73. package/hooks/post-write.py +219 -0
  74. package/hooks/post_write.py +46 -0
  75. package/hooks/pre-compact.py +398 -0
  76. package/hooks/pre-tool-inject.py +98 -0
  77. package/hooks/prompt-enhancer.py +672 -0
  78. package/hooks/quality-runner.py +191 -0
  79. package/hooks/query.py +512 -0
  80. package/hooks/secret-guard.py +61 -0
  81. package/hooks/secret_audit.py +144 -0
  82. package/hooks/session-end-capture.py +137 -0
  83. package/hooks/session-start.py +277 -0
  84. package/hooks/setup_wizard.py +582 -0
  85. package/hooks/shadow_manager.py +297 -0
  86. package/hooks/state_migration.py +225 -0
  87. package/hooks/stop-gate.py +7 -0
  88. package/hooks/stop_dispatcher.py +945 -0
  89. package/hooks/test-validator.py +361 -0
  90. package/hooks/test_generator_hook.py +123 -0
  91. package/hooks/todo-state-tracker.py +114 -0
  92. package/hooks/tool-ledger.py +149 -0
  93. package/hooks/trust_review.py +585 -0
  94. package/hud/omg-hud.mjs +31 -1
  95. package/lab/__init__.py +1 -0
  96. package/lab/pipeline.py +75 -0
  97. package/lab/policies.py +52 -0
  98. package/package.json +7 -18
  99. package/plugins/README.md +33 -61
  100. package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
  101. package/plugins/advanced/commands/OMG:learn.md +1 -1
  102. package/plugins/advanced/commands/OMG:security-review.md +3 -3
  103. package/plugins/advanced/commands/OMG:ship.md +1 -1
  104. package/plugins/advanced/plugin.json +1 -1
  105. package/plugins/core/plugin.json +8 -3
  106. package/plugins/dephealth/__init__.py +0 -0
  107. package/plugins/dephealth/cve_scanner.py +188 -0
  108. package/plugins/dephealth/license_checker.py +135 -0
  109. package/plugins/dephealth/manifest_detector.py +423 -0
  110. package/plugins/dephealth/vuln_analyzer.py +169 -0
  111. package/plugins/testgen/__init__.py +0 -0
  112. package/plugins/testgen/codamosa_engine.py +402 -0
  113. package/plugins/testgen/edge_case_synthesizer.py +184 -0
  114. package/plugins/testgen/framework_detector.py +271 -0
  115. package/plugins/testgen/skeleton_generator.py +219 -0
  116. package/plugins/viz/__init__.py +0 -0
  117. package/plugins/viz/ast_parser.py +139 -0
  118. package/plugins/viz/diagram_generator.py +192 -0
  119. package/plugins/viz/graph_builder.py +444 -0
  120. package/plugins/viz/native_parsers.py +259 -0
  121. package/plugins/viz/regex_parser.py +112 -0
  122. package/pyproject.toml +81 -0
  123. package/rules/contextual/write-verify.md +2 -2
  124. package/rules/core/00-truth.md +1 -1
  125. package/rules/core/01-surgical.md +1 -1
  126. package/rules/core/02-circuit-breaker.md +2 -2
  127. package/rules/core/03-ensemble.md +3 -3
  128. package/rules/core/04-testing.md +3 -3
  129. package/runtime/__init__.py +32 -0
  130. package/runtime/adapters/__init__.py +13 -0
  131. package/runtime/adapters/claude.py +60 -0
  132. package/runtime/adapters/gpt.py +53 -0
  133. package/runtime/adapters/local.py +53 -0
  134. package/runtime/adoption.py +212 -0
  135. package/runtime/business_workflow.py +220 -0
  136. package/runtime/cli_provider.py +85 -0
  137. package/runtime/compat.py +1299 -0
  138. package/runtime/custom_agent_loader.py +366 -0
  139. package/runtime/dispatcher.py +47 -0
  140. package/runtime/ecosystem.py +371 -0
  141. package/runtime/legacy_compat.py +7 -0
  142. package/runtime/mcp_config_writers.py +115 -0
  143. package/runtime/mcp_lifecycle.py +153 -0
  144. package/runtime/mcp_memory_server.py +135 -0
  145. package/runtime/memory_parsers/__init__.py +0 -0
  146. package/runtime/memory_parsers/chatgpt_parser.py +257 -0
  147. package/runtime/memory_parsers/claude_import.py +107 -0
  148. package/runtime/memory_parsers/export.py +97 -0
  149. package/runtime/memory_parsers/gemini_import.py +91 -0
  150. package/runtime/memory_parsers/kimi_import.py +91 -0
  151. package/runtime/memory_store.py +215 -0
  152. package/runtime/omc_compat.py +7 -0
  153. package/runtime/providers/__init__.py +0 -0
  154. package/runtime/providers/codex_provider.py +112 -0
  155. package/runtime/providers/gemini_provider.py +128 -0
  156. package/runtime/providers/kimi_provider.py +151 -0
  157. package/runtime/providers/opencode_provider.py +144 -0
  158. package/runtime/subagent_dispatcher.py +362 -0
  159. package/runtime/team_router.py +1167 -0
  160. package/runtime/tmux_session_manager.py +169 -0
  161. package/scripts/check-omg-compat-contract-snapshot.py +137 -0
  162. package/scripts/check-omg-contract-snapshot.py +12 -0
  163. package/scripts/check-omg-public-ready.py +193 -0
  164. package/scripts/check-omg-standalone-clean.py +103 -0
  165. package/scripts/legacy_to_omg_migrate.py +29 -0
  166. package/scripts/migrate-legacy.py +464 -0
  167. package/scripts/omc_to_omg_migrate.py +12 -0
  168. package/scripts/omg.py +492 -0
  169. package/scripts/settings-merge.py +283 -0
  170. package/scripts/verify-standalone.sh +8 -4
  171. package/settings.json +126 -29
  172. package/templates/profile.yaml +1 -1
  173. package/tools/__init__.py +2 -0
  174. package/tools/browser_consent.py +289 -0
  175. package/tools/browser_stealth.py +481 -0
  176. package/tools/browser_tool.py +448 -0
  177. package/tools/changelog_generator.py +347 -0
  178. package/tools/commit_splitter.py +746 -0
  179. package/tools/config_discovery.py +151 -0
  180. package/tools/config_merger.py +449 -0
  181. package/tools/dashboard_generator.py +300 -0
  182. package/tools/git_inspector.py +298 -0
  183. package/tools/lsp_client.py +275 -0
  184. package/tools/lsp_discovery.py +231 -0
  185. package/tools/lsp_operations.py +392 -0
  186. package/tools/pr_generator.py +404 -0
  187. package/tools/python_repl.py +656 -0
  188. package/tools/python_sandbox.py +609 -0
  189. package/tools/search_providers/__init__.py +77 -0
  190. package/tools/search_providers/brave.py +115 -0
  191. package/tools/search_providers/exa.py +116 -0
  192. package/tools/search_providers/jina.py +104 -0
  193. package/tools/search_providers/perplexity.py +139 -0
  194. package/tools/search_providers/synthetic.py +74 -0
  195. package/tools/session_snapshot.py +736 -0
  196. package/tools/ssh_manager.py +912 -0
  197. package/tools/theme_engine.py +294 -0
  198. package/tools/theme_selector.py +137 -0
  199. package/tools/web_search.py +622 -0
  200. package/yaml.py +321 -0
  201. package/.claude-plugin/scripts/install.sh +0 -9
  202. package/bun.lock +0 -23
  203. package/bunfig.toml +0 -3
  204. package/hooks/_budget.ts +0 -1
  205. package/hooks/_common.ts +0 -63
  206. package/hooks/circuit-breaker.ts +0 -101
  207. package/hooks/config-guard.ts +0 -4
  208. package/hooks/firewall.ts +0 -20
  209. package/hooks/policy_engine.ts +0 -156
  210. package/hooks/post-tool-failure.ts +0 -22
  211. package/hooks/post-write.ts +0 -4
  212. package/hooks/pre-tool-inject.ts +0 -4
  213. package/hooks/prompt-enhancer.ts +0 -46
  214. package/hooks/quality-runner.ts +0 -24
  215. package/hooks/secret-guard.ts +0 -4
  216. package/hooks/session-end-capture.ts +0 -19
  217. package/hooks/session-start.ts +0 -19
  218. package/hooks/shadow_manager.ts +0 -81
  219. package/hooks/stop-gate.ts +0 -22
  220. package/hooks/stop_dispatcher.ts +0 -147
  221. package/hooks/test-generator-hook.ts +0 -4
  222. package/hooks/tool-ledger.ts +0 -27
  223. package/hooks/trust_review.ts +0 -175
  224. package/lab/pipeline.ts +0 -75
  225. package/lab/policies.ts +0 -68
  226. package/runtime/common.ts +0 -111
  227. package/runtime/compat.ts +0 -174
  228. package/runtime/dispatcher.ts +0 -25
  229. package/runtime/ecosystem.ts +0 -186
  230. package/runtime/provider_bootstrap.ts +0 -99
  231. package/runtime/provider_smoke.ts +0 -34
  232. package/runtime/release_readiness.ts +0 -186
  233. package/runtime/team_router.ts +0 -144
  234. package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
  235. package/scripts/check-omg-standalone-clean.ts +0 -12
  236. package/scripts/check-runtime-clean.ts +0 -94
  237. package/scripts/omg.ts +0 -352
  238. package/scripts/settings-merge.ts +0 -93
  239. package/tools/commit_splitter.ts +0 -23
  240. package/tools/git_inspector.ts +0 -18
  241. package/tools/session_snapshot.ts +0 -47
  242. package/trac3er-oh-my-god-2.0.0.tgz +0 -0
  243. package/tsconfig.json +0 -15
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env python3
2
+ """OMG v1 Policy Engine
3
+
4
+ Centralized policy decision layer for tool access, file access, and supply-chain
5
+ artifact verification.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, asdict
10
+ from fnmatch import fnmatch
11
+ import os
12
+ import re
13
+ from typing import Any
14
+
15
+
16
+ Action = str
17
+ RiskLevel = str
18
+
19
+
20
+ @dataclass
21
+ class PolicyDecision:
22
+ action: Action # allow | ask | deny
23
+ risk_level: RiskLevel # low | med | high | critical
24
+ reason: str = ""
25
+ controls: list[str] | None = None
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ data = asdict(self)
29
+ if data.get("controls") is None:
30
+ data["controls"] = []
31
+ return data
32
+
33
+
34
+ def allow(reason: str = "", controls: list[str] | None = None) -> PolicyDecision:
35
+ return PolicyDecision("allow", "low", reason, controls or [])
36
+
37
+
38
+ def ask(reason: str, risk_level: RiskLevel = "med", controls: list[str] | None = None) -> PolicyDecision:
39
+ return PolicyDecision("ask", risk_level, reason, controls or [])
40
+
41
+
42
+ def deny(reason: str, risk_level: RiskLevel = "high", controls: list[str] | None = None) -> PolicyDecision:
43
+ return PolicyDecision("deny", risk_level, reason, controls or [])
44
+
45
+
46
+ # === BASH POLICY ============================================================
47
+
48
+ DESTRUCT_PATTERNS = [
49
+ (r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+/(\s|$|\*)", "rm -rf /"),
50
+ (r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+~/?(\s|$|\*)", "rm -rf ~"),
51
+ (r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+\$HOME", "rm -rf $HOME"),
52
+ (r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+\$\{?HOME\}?", "rm -rf ${HOME}"),
53
+ (r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+\.\.\s", "rm -rf .."),
54
+ (r":\(\)\s*\{\s*:\|:&\s*\}\s*;:", "fork bomb"),
55
+ (r"function\s+\w+\(\)\s*\{\s*\w+\s*\|\s*\w+\s*&", "potential fork bomb"),
56
+ (r">\s*/dev/sd[a-z]", "overwrite disk"),
57
+ (r"dd\s+.*of=/dev/sd[a-z]", "dd to disk device"),
58
+ (r"sudo\s+(dd|mkfs|fdisk|parted|wipefs)\b", "destructive disk op"),
59
+ (r"sudo\s+rm\b", "sudo rm"),
60
+ (r"echo\s+.*>\s*/proc/", "write to /proc"),
61
+ (r"echo\s+.*>\s*/sys/", "write to /sys"),
62
+ ]
63
+
64
+ PIPE_SHELL_PATTERNS = [
65
+ r"(curl|wget)\s+.*\|\s*(sudo\s+)?(ba)?sh",
66
+ r"(curl|wget)\s+.*\|\s*python[23]?",
67
+ r"(curl|wget)\s+.*\|\s*perl",
68
+ r"(curl|wget)\s+.*\|\s*ruby",
69
+ r"base64\s+.*\|\s*(ba)?sh",
70
+ r"echo\s+.*\|\s*base64\s+-d\s*\|\s*(ba)?sh",
71
+ ]
72
+
73
+ EVAL_PATTERNS = [
74
+ r"\beval\s+\"\$",
75
+ r"\beval\s+\$\(",
76
+ r"\beval\s+`",
77
+ ]
78
+
79
+ SAFE_ENV_REFERENCE = re.compile(r"\.env\.(example|sample|template)\b", re.IGNORECASE)
80
+
81
+ SECRET_FILE_PATTERNS = [
82
+ r"\.(env|pem|key|p12|pfx|jks|keystore|netrc|npmrc|pypirc)\b",
83
+ r"/\.aws/(credentials|config)\b",
84
+ r"/\.kube/config\b",
85
+ r"/id_(rsa|ed25519|ecdsa)\b",
86
+ r"/\.ssh/",
87
+ r"\bsecrets?/",
88
+ r"\bcredentials?\.",
89
+ r"\bpasswords?\.",
90
+ r"\btokens?\.",
91
+ ]
92
+
93
+ READ_COMMANDS = [
94
+ "cat", "less", "more", "head", "tail", "strings", "xxd", "od",
95
+ "hexdump", "base64", "vim", "vi", "nano", "emacs", "view",
96
+ "bat", "pygmentize", "highlight", "source", "\\.",
97
+ "awk", "gawk", "mawk", "perl", "ruby", "python", "python3", "node",
98
+ ]
99
+ READ_PATTERN = r"(?:^|\s|;|&&|\|\|)(?:" + "|".join(re.escape(c) for c in READ_COMMANDS) + r")\s+"
100
+
101
+ EXFIL_COMMANDS = [
102
+ r"\b(cp|mv|ln\s+-s)\s+",
103
+ r"\btar\s+.*-?c",
104
+ r"\bzip\s+",
105
+ ]
106
+
107
+ ASK_PATTERNS = [
108
+ (r"(^|\s)(curl|wget)(\s|$)", "Network egress"),
109
+ (r"(^|\s)(ssh|scp|rsync)(\s|$)", "Remote connection"),
110
+ (r"git\s+push\s+.*(-f|--force)", "Force push"),
111
+ (r"git\s+push\s+.*(main|master|production|release)", "Push to protected branch"),
112
+ (r"chmod\s+(777|666|a\+[rwx])", "Overly permissive chmod"),
113
+ (r"docker\s+run\s+.*--privileged", "Privileged container"),
114
+ (r"python[23]?\s+-c\s+", "Inline Python execution"),
115
+ (r"node\s+-e\s+", "Inline Node execution"),
116
+ ]
117
+
118
+
119
+ def evaluate_bash_command(cmd: str) -> PolicyDecision:
120
+ if not cmd:
121
+ return allow("empty command")
122
+
123
+ for pat, label in DESTRUCT_PATTERNS:
124
+ if re.search(pat, cmd):
125
+ return deny(f"Blocked: {label}", "critical", ["destructive-op"])
126
+
127
+ for pat in PIPE_SHELL_PATTERNS:
128
+ if re.search(pat, cmd):
129
+ return deny("Blocked: pipe-to-shell", "critical", ["remote-code-exec"])
130
+
131
+ for pat in EVAL_PATTERNS:
132
+ if re.search(pat, cmd):
133
+ return deny("Blocked: dynamic eval", "high", ["dynamic-eval"])
134
+
135
+ for secret_pat in SECRET_FILE_PATTERNS:
136
+ if not re.search(secret_pat, cmd, re.IGNORECASE):
137
+ continue
138
+
139
+ if SAFE_ENV_REFERENCE.search(cmd):
140
+ cleaned = SAFE_ENV_REFERENCE.sub("__SAFE_REF__", cmd)
141
+ if not re.search(secret_pat, cleaned, re.IGNORECASE):
142
+ continue
143
+
144
+ if re.search(READ_PATTERN, cmd, re.IGNORECASE):
145
+ return deny("Blocked: reading secret file", "critical", ["secret-access"])
146
+
147
+ if re.search(r"<\s*\S*(" + secret_pat + r")", cmd, re.IGNORECASE):
148
+ return deny("Blocked: reading secret file via redirect", "critical", ["secret-access"])
149
+
150
+ for exfil in EXFIL_COMMANDS:
151
+ if re.search(exfil, cmd):
152
+ return deny("Blocked: copying secret file", "critical", ["secret-exfiltration"])
153
+
154
+ if re.search(r"\bgrep\b", cmd):
155
+ return ask("Searching inside potential secret file — confirm this is safe", "high", ["secret-search"])
156
+
157
+ for pat, label in ASK_PATTERNS:
158
+ if re.search(pat, cmd):
159
+ return ask(f"{label}: {cmd[:120]}", "med", ["human-approval"])
160
+
161
+ return allow("command allowed")
162
+
163
+
164
+ # === FILE POLICY ============================================================
165
+
166
+ BLOCKED_FILES = {
167
+ ".env", ".env.local", ".env.development", ".env.production",
168
+ ".env.staging", ".env.test", ".npmrc", ".pypirc", ".netrc",
169
+ "id_rsa", "id_ed25519", "id_ecdsa", "id_rsa.pub", "id_ed25519.pub", "id_ecdsa.pub",
170
+ }
171
+
172
+ EXAMPLE_FILES = {".env.example", ".env.sample", ".env.template"}
173
+
174
+ BLOCKED_PATH_PATTERNS = [
175
+ r"/\.aws/(credentials|config)$",
176
+ r"/\.kube/config$",
177
+ r"/\.ssh/",
178
+ r"/\.gnupg/",
179
+ r"/secrets?/",
180
+ r"\.(pem|key|p12|pfx|jks|keystore)$",
181
+ r"(^|/)secret[s]?\.",
182
+ r"(^|/)credential[s]?\.",
183
+ r"(^|/)password[s]?\.",
184
+ r"(^|/)token[s]?\.",
185
+ r"(^|/)\.docker/config\.json$",
186
+ r"(^|/)\.git-credentials$",
187
+ ]
188
+
189
+
190
+ # OMG internal credential store paths (exempted from secret-file blocking)
191
+ # Only these exact filenames inside .omg/state/ are allowed.
192
+ _OMG_CREDENTIAL_STORE_ALLOWLIST = frozenset({
193
+ "credentials.enc",
194
+ "credentials.meta",
195
+ })
196
+
197
+
198
+ def _is_omg_credential_path(normalized_path: str) -> bool:
199
+ """Return True if the path is an OMG credential store file.
200
+
201
+ Only exempts files that are:
202
+ 1. Inside .omg/state/ directory
203
+ 2. Named exactly 'credentials.enc' or 'credentials.meta'
204
+ 3. Feature flag MULTI_CREDENTIAL is enabled
205
+
206
+ This is deliberately narrow to prevent path traversal attacks.
207
+ """
208
+ # Import here to avoid circular dependency at module level
209
+ from _common import get_feature_flag
210
+
211
+ # Only exempt if feature is enabled
212
+ if not get_feature_flag("MULTI_CREDENTIAL", default=False):
213
+ return False
214
+
215
+ basename = os.path.basename(normalized_path).lower()
216
+ if basename not in _OMG_CREDENTIAL_STORE_ALLOWLIST:
217
+ return False
218
+
219
+ # Verify it's actually inside .omg/state/
220
+ parent = os.path.dirname(normalized_path)
221
+ return parent.endswith(os.sep + ".omg" + os.sep + "state") or \
222
+ parent.endswith("/.omg/state")
223
+
224
+
225
+ # === ALLOWLIST SUPPORT =======================================================
226
+
227
+ # Globs that are too broad to be safe — reject these in allowlist entries.
228
+ OVERLY_BROAD_GLOBS = frozenset({
229
+ "*", "**", "**/*", "**/**", "*/*", "*/**",
230
+ })
231
+
232
+
233
+ def validate_allowlist_entry(entry: dict[str, Any]) -> None:
234
+ """Validate a single allowlist entry.
235
+
236
+ Schema: {"path": "glob", "tools": ["Read", "Write"], "reason": "text"}
237
+
238
+ Raises ValueError if the entry is invalid.
239
+ """
240
+ if not isinstance(entry, dict):
241
+ raise ValueError("Allowlist entry must be a dict")
242
+
243
+ for field in ("path", "tools", "reason"):
244
+ if field not in entry:
245
+ raise ValueError(f"Missing required field: {field}")
246
+
247
+ path = entry["path"]
248
+ if path in OVERLY_BROAD_GLOBS:
249
+ raise ValueError(f"Overly broad glob rejected: {path}")
250
+
251
+ tools = entry["tools"]
252
+ if not isinstance(tools, list) or not tools:
253
+ raise ValueError("tools must be a non-empty list")
254
+
255
+
256
+ def is_allowlisted(file_path: str, tool: str, allowlist: list[dict[str, Any]]) -> bool:
257
+ """Check if a file_path + tool combination is allowlisted.
258
+
259
+ Matches the file's basename and normalized path against allowlist globs.
260
+ Invalid entries are silently skipped.
261
+
262
+ Returns True if the path+tool matches any valid allowlist entry.
263
+ """
264
+ if not allowlist:
265
+ return False
266
+
267
+ normalized = os.path.normpath(file_path)
268
+ basename = os.path.basename(normalized)
269
+
270
+ for entry in allowlist:
271
+ try:
272
+ validate_allowlist_entry(entry)
273
+ except (ValueError, TypeError):
274
+ continue
275
+
276
+ pattern = entry["path"]
277
+ entry_tools = entry["tools"]
278
+
279
+ # Match against basename or full normalized path
280
+ if fnmatch(basename, pattern) or fnmatch(normalized, pattern):
281
+ if tool in entry_tools:
282
+ _log_allowlist_bypass(
283
+ file_path, tool, entry.get("reason", "")
284
+ )
285
+ return True
286
+
287
+ return False
288
+
289
+
290
+ def load_allowlist(project_dir: str = ".") -> list[dict[str, Any]]:
291
+ """Load allowlist entries from .omg/policy.yaml.
292
+
293
+ Returns a list of valid allowlist entries. Invalid entries (overly broad
294
+ globs, missing fields) are filtered out silently.
295
+
296
+ Returns empty list if file doesn't exist or has no allowlist section.
297
+ """
298
+ policy_path = os.path.join(project_dir, ".omg", "policy.yaml")
299
+ if not os.path.isfile(policy_path):
300
+ return []
301
+
302
+ try:
303
+ import yaml
304
+ with open(policy_path, "r") as f:
305
+ data = yaml.safe_load(f)
306
+ except ImportError:
307
+ # Fallback: no yaml module — try simple line-by-line parse
308
+ data = _parse_policy_yaml_fallback(policy_path)
309
+ except Exception:
310
+ return []
311
+
312
+ if not isinstance(data, dict):
313
+ return []
314
+
315
+ raw_allowlist = data.get("allowlist")
316
+ if not isinstance(raw_allowlist, list):
317
+ return []
318
+
319
+ # Filter out invalid entries
320
+ valid = []
321
+ for entry in raw_allowlist:
322
+ try:
323
+ validate_allowlist_entry(entry)
324
+ valid.append(entry)
325
+ except (ValueError, TypeError):
326
+ continue
327
+
328
+ return valid
329
+
330
+
331
+ def _parse_policy_yaml_fallback(path: str) -> dict[str, Any]:
332
+ """Minimal YAML-like parser for allowlist section only.
333
+
334
+ Used when PyYAML is not available. Handles simple allowlist entries.
335
+ """
336
+ try:
337
+ with open(path, "r") as f:
338
+ lines = f.readlines()
339
+ except Exception:
340
+ return {}
341
+
342
+ result: dict[str, Any] = {}
343
+ in_allowlist = False
344
+ allowlist: list[dict[str, Any]] = []
345
+ current_entry: dict[str, Any] | None = None
346
+
347
+ for line in lines:
348
+ stripped = line.rstrip()
349
+
350
+ if stripped == "allowlist:":
351
+ in_allowlist = True
352
+ continue
353
+
354
+ if in_allowlist:
355
+ # Detect end of allowlist section (new top-level key)
356
+ if stripped and not stripped.startswith(" ") and not stripped.startswith("\t"):
357
+ in_allowlist = False
358
+ continue
359
+
360
+ # New list entry
361
+ if stripped.lstrip().startswith("- path:"):
362
+ if current_entry is not None:
363
+ allowlist.append(current_entry)
364
+ val = stripped.split(":", 1)[1].strip().strip("'\"")
365
+ current_entry = {"path": val, "tools": [], "reason": ""}
366
+ elif current_entry is not None:
367
+ clean = stripped.strip()
368
+ if clean.startswith("reason:"):
369
+ current_entry["reason"] = clean.split(":", 1)[1].strip().strip("'\"")
370
+ elif clean.startswith("- ") and "tools" not in clean:
371
+ current_entry["tools"].append(clean[2:].strip().strip("'\""))
372
+
373
+ if current_entry is not None:
374
+ allowlist.append(current_entry)
375
+
376
+ if allowlist:
377
+ result["allowlist"] = allowlist
378
+
379
+ return result
380
+
381
+
382
+ def _log_allowlist_bypass(path: str, tool: str, reason: str) -> None:
383
+ """Record that an allowlist entry overrode a deny decision.
384
+
385
+ Writes an audit trail entry to .omg/state/ledger/secret-access.jsonl
386
+ with allowlisted=True. Uses CLAUDE_PROJECT_DIR or cwd as project root.
387
+ Silently fails — never raises exceptions (crash isolation invariant).
388
+ """
389
+ try:
390
+ from secret_audit import log_secret_access
391
+
392
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
393
+ log_secret_access(
394
+ project_dir=project_dir,
395
+ tool=tool,
396
+ file_path=path,
397
+ decision="allow",
398
+ reason=f"allowlist bypass: {reason}",
399
+ allowlisted=True,
400
+ )
401
+ except Exception:
402
+ pass # Crash isolation: audit logging must never break policy evaluation
403
+
404
+
405
+ def evaluate_file_access(
406
+ tool: str,
407
+ file_path: str,
408
+ allowlist: list[dict[str, Any]] | None = None,
409
+ ) -> PolicyDecision:
410
+ """Evaluate file access policy.
411
+
412
+ If an allowlist is provided, matching entries override deny decisions
413
+ for the given path and tool combination.
414
+ """
415
+ if not file_path:
416
+ return allow("no file")
417
+
418
+ normalized = os.path.normpath(file_path)
419
+ # Resolve symlinks to prevent bypass via symlink to secret file
420
+ try:
421
+ normalized = os.path.realpath(normalized)
422
+ except (OSError, ValueError):
423
+ pass
424
+ basename = os.path.basename(normalized).lower()
425
+ lowpath = normalized.lower()
426
+
427
+ # --- Allowlist check (before deny rules) ---
428
+ # Check allowlist early: if path+tool is allowlisted, override deny.
429
+ if allowlist and is_allowlisted(file_path, tool, allowlist):
430
+ return allow(f"Allowlisted: {file_path}")
431
+
432
+ if basename in EXAMPLE_FILES and tool in ("Write", "Edit", "MultiEdit"):
433
+ return deny(
434
+ f"Modifying example env file blocked (Read is allowed): {file_path}",
435
+ "high",
436
+ ["immutable-env-template"],
437
+ )
438
+
439
+ if basename in BLOCKED_FILES:
440
+ return deny(f"Secret file blocked: {file_path}", "critical", ["secret-access"])
441
+
442
+ if re.match(r"^\.env(\..+)?$", basename) and basename not in EXAMPLE_FILES:
443
+ return deny(f"Environment file blocked: {file_path}", "critical", ["secret-access"])
444
+
445
+ # EXEMPTION: OMG credential store files within .omg/state/
446
+ # These are managed by hooks/credential_store.py and must be accessible
447
+ if _is_omg_credential_path(normalized):
448
+ return allow("OMG credential store (managed path)")
449
+
450
+ for pat in BLOCKED_PATH_PATTERNS:
451
+ if re.search(pat, lowpath):
452
+ return deny(f"Sensitive path blocked: {file_path}", "critical", ["secret-access"])
453
+
454
+ return allow("file allowed")
455
+
456
+
457
+ # === SUPPLY CHAIN POLICY ====================================================
458
+
459
+
460
+ def evaluate_supply_artifact(artifact: dict[str, Any], mode: str = "warn_and_run") -> PolicyDecision:
461
+ """Verify artifact trust with Warn-And-Run semantics.
462
+
463
+ mode=warn_and_run: missing trust metadata returns ASK
464
+ critical findings always DENY
465
+ """
466
+ findings = artifact.get("static_scan") or []
467
+ permissions = artifact.get("permissions") or []
468
+ signer = artifact.get("signer")
469
+ checksum = artifact.get("checksum")
470
+
471
+ for finding in findings:
472
+ sev = str((finding or {}).get("severity", "")).lower()
473
+ if sev == "critical":
474
+ return deny("Critical static-scan finding detected", "critical", ["supply-critical-block"])
475
+
476
+ joined_perms = " ".join(str(p) for p in permissions)
477
+ if any(token in joined_perms for token in ["sudo", "rm -rf", "--privileged", "curl |", "wget |"]):
478
+ return deny("Critical permission profile detected in artifact", "critical", ["dangerous-permissions"])
479
+
480
+ if not signer or not checksum:
481
+ if mode == "warn_and_run":
482
+ return ask(
483
+ "Artifact missing signer/checksum metadata (untrusted). Continue with isolation.",
484
+ "high",
485
+ ["isolate-network", "read-only-fs", "manual-approval"],
486
+ )
487
+ return deny("Artifact missing signer/checksum metadata", "high", ["unsigned-artifact"])
488
+
489
+ has_high = any(str((finding or {}).get("severity", "")).lower() == "high" for finding in findings)
490
+ if has_high:
491
+ return ask("High-risk findings present. Explicit approval required.", "high", ["manual-approval"])
492
+
493
+ return allow("artifact trusted")
494
+
495
+
496
+ def to_pretool_hook_output(decision: PolicyDecision) -> dict[str, Any] | None:
497
+ if decision.action == "allow":
498
+ return None
499
+ return {
500
+ "hookSpecificOutput": {
501
+ "hookEventName": "PreToolUse",
502
+ "permissionDecision": decision.action,
503
+ "permissionDecisionReason": decision.reason,
504
+ }
505
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUseFailure Hook — Logs tool failures for enhanced tracking."""
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.dirname(__file__))
7
+
8
+ from _common import setup_crash_handler, json_input, get_feature_flag, log_hook_error
9
+
10
+ setup_crash_handler('post-tool-failure')
11
+
12
+ data = json_input()
13
+ tool_name = data.get('tool_name', 'unknown')
14
+ error = data.get('error', data.get('message', 'unknown error'))
15
+
16
+ # Log to hook-errors.jsonl using the shared utility
17
+ log_hook_error('post-tool-failure', error, context={'tool': tool_name})
18
+
19
+ sys.exit(0)