@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,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)
|