@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,912 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SSH Connection Manager for OMG
4
+
5
+ Manages SSH connection specs without requiring actual SSH libraries.
6
+ Functions are SPEC GENERATORS — they don't make real SSH connections.
7
+ Connection pool tracks connection metadata for orchestration use.
8
+
9
+ Feature flag: OMG_SSH_ENABLED (default: False)
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import sys
16
+ import uuid
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Dict, List, Optional
20
+
21
+
22
+ # --- Lazy imports for hooks/_common.py ---
23
+
24
+ _get_feature_flag = None
25
+ _atomic_json_write = None
26
+
27
+
28
+ def _ensure_imports():
29
+ """Lazy import feature flag and atomic write from hooks/_common.py."""
30
+ global _get_feature_flag, _atomic_json_write
31
+ if _get_feature_flag is not None:
32
+ return
33
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
34
+ if repo_root not in sys.path:
35
+ sys.path.insert(0, repo_root)
36
+ try:
37
+ from hooks._common import get_feature_flag as _gff
38
+ from hooks._common import atomic_json_write as _ajw
39
+ _get_feature_flag = _gff
40
+ _atomic_json_write = _ajw
41
+ except ImportError:
42
+ pass
43
+
44
+
45
+ # --- Feature flag ---
46
+
47
+ def _is_enabled() -> bool:
48
+ """Check if SSH feature is enabled."""
49
+ # Fast path: check env var directly
50
+ env_val = os.environ.get("OMG_SSH_ENABLED", "").lower()
51
+ if env_val in ("0", "false", "no"):
52
+ return False
53
+ if env_val in ("1", "true", "yes"):
54
+ return True
55
+ # Fallback to hooks/_common.get_feature_flag
56
+ _ensure_imports()
57
+ if _get_feature_flag is not None:
58
+ return _get_feature_flag("SSH", default=False)
59
+ return False
60
+
61
+
62
+ # --- Response helpers ---
63
+
64
+ def _success_response(result: Any) -> Dict[str, Any]:
65
+ """Create a success response dict."""
66
+ return {"success": True, "result": result, "error": None}
67
+
68
+
69
+ def _error_response(error: str) -> Dict[str, Any]:
70
+ """Create an error response dict."""
71
+ return {"success": False, "result": None, "error": error}
72
+
73
+
74
+ def _disabled_response() -> Dict[str, Any]:
75
+ """Create a response for when the feature flag is disabled."""
76
+ return _error_response("SSH feature is disabled (OMG_SSH_ENABLED=false)")
77
+
78
+
79
+ # =============================================================================
80
+ # Data Types
81
+ # =============================================================================
82
+
83
+
84
+ @dataclass
85
+ class SSHConnection:
86
+ """SSH connection specification.
87
+
88
+ Represents an SSH connection target with authentication details.
89
+ This is a data container — no actual SSH connection is made.
90
+
91
+ Attributes:
92
+ host: Hostname or IP address.
93
+ port: SSH port number (default: 22).
94
+ user: Username for SSH authentication.
95
+ key_path: Path to SSH private key file (optional).
96
+ password: Password indicator — never stores actual password.
97
+ shell: Default shell on the remote host.
98
+ os_type: Operating system type of the remote host.
99
+ """
100
+ host: str
101
+ port: int = 22
102
+ user: str = ""
103
+ key_path: Optional[str] = None
104
+ password: Optional[str] = None
105
+ shell: str = "bash"
106
+ os_type: str = "linux"
107
+
108
+ def to_dict(self) -> Dict[str, Any]:
109
+ """Convert to a plain dictionary.
110
+
111
+ Passwords are never included — only a ``password_set`` indicator.
112
+ """
113
+ data = asdict(self)
114
+ # Never expose password in plain text
115
+ has_password = data.pop("password", None) is not None
116
+ data["password_set"] = has_password
117
+ return data
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: Dict[str, Any]) -> "SSHConnection":
121
+ """Create an SSHConnection from a dictionary.
122
+
123
+ Accepts dicts with at least a 'host' key.
124
+ Missing keys use defaults.
125
+ """
126
+ # Handle password_set indicator from serialized form
127
+ password = None
128
+ if data.get("password"):
129
+ password = data["password"]
130
+ elif data.get("password_set"):
131
+ # Marker only — actual password is not stored
132
+ password = "__SET__"
133
+
134
+ return cls(
135
+ host=data.get("host", ""),
136
+ port=int(data.get("port", 22)),
137
+ user=data.get("user", ""),
138
+ key_path=data.get("key_path"),
139
+ password=password,
140
+ shell=data.get("shell", "bash"),
141
+ os_type=data.get("os_type", "linux"),
142
+ )
143
+
144
+
145
+ # =============================================================================
146
+ # Connection Pool — module-level state
147
+ # =============================================================================
148
+
149
+ # Active connections keyed by "host:port"
150
+ _connections: Dict[str, Dict[str, Any]] = {}
151
+
152
+ # Active SSHFS mounts keyed by local_path
153
+ _mounts: Dict[str, Dict[str, Any]] = {}
154
+
155
+
156
+ def _pool_key(host: str, port: int = 22) -> str:
157
+ """Generate a connection pool key."""
158
+ return f"{host}:{port}"
159
+
160
+
161
+ # =============================================================================
162
+ # Host Discovery
163
+ # =============================================================================
164
+
165
+
166
+ def discover_hosts(project_dir: str = ".") -> List[SSHConnection]:
167
+ """Discover SSH hosts from project configuration files.
168
+
169
+ Reads ``ssh.json`` or ``.ssh.json`` from the project directory.
170
+ Returns an empty list if the feature flag is disabled, or if no
171
+ configuration file is found.
172
+
173
+ Expected JSON format::
174
+
175
+ {
176
+ "hosts": [
177
+ {
178
+ "host": "server.example.com",
179
+ "port": 22,
180
+ "user": "ubuntu",
181
+ "key_path": "~/.ssh/id_rsa"
182
+ }
183
+ ]
184
+ }
185
+
186
+ Args:
187
+ project_dir: Directory to search for ssh.json files.
188
+
189
+ Returns:
190
+ A list of SSHConnection objects discovered from configuration.
191
+ """
192
+ if not _is_enabled():
193
+ return []
194
+
195
+ abs_dir = os.path.abspath(project_dir)
196
+
197
+ # Try ssh.json first, then .ssh.json
198
+ for filename in ("ssh.json", ".ssh.json"):
199
+ config_path = os.path.join(abs_dir, filename)
200
+ if os.path.isfile(config_path):
201
+ try:
202
+ with open(config_path, "r", encoding="utf-8") as f:
203
+ data = json.load(f)
204
+ except (json.JSONDecodeError, OSError):
205
+ return []
206
+
207
+ hosts_data = data.get("hosts", [])
208
+ if not isinstance(hosts_data, list):
209
+ return []
210
+
211
+ connections = []
212
+ for entry in hosts_data:
213
+ if isinstance(entry, dict) and entry.get("host"):
214
+ # Expand ~ in key_path
215
+ if entry.get("key_path"):
216
+ entry["key_path"] = os.path.expanduser(entry["key_path"])
217
+ connections.append(SSHConnection.from_dict(entry))
218
+ return connections
219
+
220
+ return []
221
+
222
+
223
+ # =============================================================================
224
+ # Connection Management
225
+ # =============================================================================
226
+
227
+
228
+ def connect(
229
+ host: str,
230
+ port: int = 22,
231
+ user: Optional[str] = None,
232
+ key_path: Optional[str] = None,
233
+ password: Optional[str] = None,
234
+ ) -> Dict[str, Any]:
235
+ """Create a connection spec and add it to the connection pool.
236
+
237
+ This does NOT make an actual SSH connection — it generates a
238
+ connection specification dict and registers it in the pool.
239
+
240
+ Args:
241
+ host: Hostname or IP address.
242
+ port: SSH port (default: 22).
243
+ user: Username for authentication.
244
+ key_path: Path to SSH private key.
245
+ password: Password for authentication (never stored in plaintext).
246
+
247
+ Returns:
248
+ A dict with connection spec on success, or error response if disabled.
249
+ """
250
+ if not _is_enabled():
251
+ return _disabled_response()
252
+
253
+ if not host or not isinstance(host, str):
254
+ return _error_response("Host must be a non-empty string")
255
+
256
+ # SSH policy check — block unapproved hosts
257
+ policy = _check_ssh_policy(host, port)
258
+ if not policy["allowed"]:
259
+ return {
260
+ "success": False,
261
+ "result": None,
262
+ "error": "Host not approved. Call approve_host() first.",
263
+ "requires_approval": True,
264
+ }
265
+
266
+ session_id = uuid.uuid4().hex[:12]
267
+ key = _pool_key(host, port)
268
+
269
+ spec = {
270
+ "host": host,
271
+ "port": port,
272
+ "user": user or os.environ.get("USER", ""),
273
+ "connected": True,
274
+ "session_id": session_id,
275
+ }
276
+
277
+ # Key path expansion
278
+ if key_path:
279
+ spec["key_path"] = os.path.expanduser(key_path)
280
+
281
+ # Password indicator — NEVER store actual password
282
+ spec["password_set"] = password is not None
283
+
284
+ # Add to connection pool
285
+ _connections[key] = spec
286
+
287
+ return _success_response(spec)
288
+
289
+
290
+ def disconnect(host: str, port: int = 22) -> bool:
291
+ """Remove a connection from the pool.
292
+
293
+ Args:
294
+ host: Hostname or IP of the connection to remove.
295
+ port: Port of the connection (default: 22).
296
+
297
+ Returns:
298
+ True if the connection was found and removed, False otherwise.
299
+ """
300
+ if not _is_enabled():
301
+ return False
302
+
303
+ key = _pool_key(host, port)
304
+ if key in _connections:
305
+ del _connections[key]
306
+ return True
307
+ return False
308
+
309
+
310
+ def get_connections() -> List[Dict[str, Any]]:
311
+ """List all active connections in the pool.
312
+
313
+ Returns:
314
+ A list of connection spec dicts. Empty list if disabled.
315
+ """
316
+ if not _is_enabled():
317
+ return []
318
+
319
+ return list(_connections.values())
320
+
321
+
322
+ # =============================================================================
323
+ # OS and Shell Detection
324
+ # =============================================================================
325
+
326
+
327
+ def detect_os(connection: Optional[Dict[str, Any]] = None) -> str:
328
+ """Detect the operating system of a connection target.
329
+
330
+ Since this is a spec generator (no actual SSH), returns sensible
331
+ defaults based on connection metadata or "linux" as fallback.
332
+
333
+ Args:
334
+ connection: A connection spec dict (optional).
335
+
336
+ Returns:
337
+ One of "linux", "macos", "windows".
338
+ """
339
+ if not _is_enabled():
340
+ return "unknown"
341
+
342
+ if connection and isinstance(connection, dict):
343
+ # Check if os_type was provided in connection metadata
344
+ os_type = connection.get("os_type", "").lower()
345
+ if os_type in ("linux", "macos", "windows"):
346
+ return os_type
347
+
348
+ # Heuristic: check host name patterns
349
+ host = connection.get("host", "").lower()
350
+ if "win" in host or "windows" in host:
351
+ return "windows"
352
+ if "mac" in host or "darwin" in host:
353
+ return "macos"
354
+
355
+ return "linux"
356
+
357
+
358
+ def detect_shell(connection: Optional[Dict[str, Any]] = None) -> str:
359
+ """Detect the default shell of a connection target.
360
+
361
+ Since this is a spec generator (no actual SSH), returns sensible
362
+ defaults based on connection metadata or OS type.
363
+
364
+ Args:
365
+ connection: A connection spec dict (optional).
366
+
367
+ Returns:
368
+ One of "bash", "zsh", "sh", "powershell".
369
+ """
370
+ if not _is_enabled():
371
+ return "unknown"
372
+
373
+ if connection and isinstance(connection, dict):
374
+ # Check if shell was provided in connection metadata
375
+ shell = connection.get("shell", "").lower()
376
+ if shell in ("bash", "zsh", "sh", "powershell", "fish"):
377
+ return shell
378
+
379
+ # Infer from OS
380
+ os_type = detect_os(connection)
381
+ if os_type == "windows":
382
+ return "powershell"
383
+ if os_type == "macos":
384
+ return "zsh"
385
+
386
+ return "bash"
387
+
388
+
389
+ # =============================================================================
390
+ # SSHFS Mount Management
391
+ # =============================================================================
392
+
393
+
394
+ def mount_sshfs(
395
+ host: str,
396
+ remote_path: str,
397
+ local_path: str,
398
+ user: Optional[str] = None,
399
+ key_path: Optional[str] = None,
400
+ port: int = 22,
401
+ ) -> Dict[str, Any]:
402
+ """Create an SSHFS mount spec and register it.
403
+
404
+ This is a SPEC GENERATOR — no actual ``sshfs`` subprocess call is made.
405
+ The mount spec is stored in the module-level ``_mounts`` registry.
406
+
407
+ Args:
408
+ host: Remote hostname or IP.
409
+ remote_path: Path on the remote host to mount.
410
+ local_path: Local mount point path.
411
+ user: Username for SSH authentication.
412
+ key_path: Path to SSH private key.
413
+ port: SSH port (default: 22).
414
+
415
+ Returns:
416
+ Mount spec dict with success status and mount details.
417
+ """
418
+ if not _is_enabled():
419
+ return _disabled_response()
420
+
421
+ if not host or not isinstance(host, str):
422
+ return _error_response("Host must be a non-empty string")
423
+
424
+ if not remote_path or not isinstance(remote_path, str):
425
+ return _error_response("Remote path must be a non-empty string")
426
+
427
+ if not local_path or not isinstance(local_path, str):
428
+ return _error_response("Local path must be a non-empty string")
429
+
430
+ mount_id = f"{host}:{remote_path}"
431
+ abs_local = os.path.abspath(local_path)
432
+
433
+ spec = {
434
+ "success": True,
435
+ "host": host,
436
+ "remote_path": remote_path,
437
+ "local_path": abs_local,
438
+ "mounted": True,
439
+ "mount_id": mount_id,
440
+ "port": port,
441
+ "user": user or os.environ.get("USER", ""),
442
+ }
443
+
444
+ if key_path:
445
+ spec["key_path"] = os.path.expanduser(key_path)
446
+
447
+ _mounts[abs_local] = spec
448
+ return spec
449
+
450
+
451
+ def unmount_sshfs(local_path: str) -> Dict[str, Any]:
452
+ """Remove an SSHFS mount from the registry.
453
+
454
+ Args:
455
+ local_path: Local mount point to unmount.
456
+
457
+ Returns:
458
+ Dict with success status and unmount details.
459
+ """
460
+ if not _is_enabled():
461
+ return _disabled_response()
462
+
463
+ abs_local = os.path.abspath(local_path)
464
+
465
+ if abs_local not in _mounts:
466
+ return _error_response(f"No mount found at {abs_local}")
467
+
468
+ removed = _mounts.pop(abs_local)
469
+ return {
470
+ "success": True,
471
+ "host": removed["host"],
472
+ "remote_path": removed["remote_path"],
473
+ "local_path": abs_local,
474
+ "mounted": False,
475
+ "mount_id": removed["mount_id"],
476
+ }
477
+
478
+
479
+ def get_mounts() -> List[Dict[str, Any]]:
480
+ """List all active SSHFS mounts.
481
+
482
+ Returns:
483
+ List of mount spec dicts. Empty list if disabled.
484
+ """
485
+ if not _is_enabled():
486
+ return []
487
+
488
+ return list(_mounts.values())
489
+
490
+
491
+ def cleanup_mounts() -> int:
492
+ """Unmount all SSHFS mounts and clear the registry.
493
+
494
+ Returns:
495
+ Number of mounts that were cleaned up.
496
+ """
497
+ if not _is_enabled():
498
+ return 0
499
+
500
+ count = len(_mounts)
501
+ _mounts.clear()
502
+ return count
503
+
504
+
505
+ def auto_mount_from_config(project_dir: str = ".") -> List[Dict[str, Any]]:
506
+ """Read sshfs_mounts from ssh.json config and mount them.
507
+
508
+ Reads ``ssh.json`` or ``.ssh.json`` from the project directory
509
+ and processes the ``sshfs_mounts`` key.
510
+
511
+ Expected JSON format::
512
+
513
+ {
514
+ "sshfs_mounts": [
515
+ {
516
+ "host": "server.example.com",
517
+ "remote_path": "/home/user",
518
+ "local_path": "/mnt/remote",
519
+ "user": "ubuntu",
520
+ "port": 22
521
+ }
522
+ ]
523
+ }
524
+
525
+ Args:
526
+ project_dir: Directory to search for ssh.json files.
527
+
528
+ Returns:
529
+ List of mount spec dicts for successfully registered mounts.
530
+ """
531
+ if not _is_enabled():
532
+ return []
533
+
534
+ abs_dir = os.path.abspath(project_dir)
535
+
536
+ for filename in ("ssh.json", ".ssh.json"):
537
+ config_path = os.path.join(abs_dir, filename)
538
+ if os.path.isfile(config_path):
539
+ try:
540
+ with open(config_path, "r", encoding="utf-8") as f:
541
+ data = json.load(f)
542
+ except (json.JSONDecodeError, OSError):
543
+ return []
544
+
545
+ mounts_data = data.get("sshfs_mounts", [])
546
+ if not isinstance(mounts_data, list):
547
+ return []
548
+
549
+ results = []
550
+ for entry in mounts_data:
551
+ if not isinstance(entry, dict):
552
+ continue
553
+ host = entry.get("host", "")
554
+ remote_path = entry.get("remote_path", "")
555
+ local_path = entry.get("local_path", "")
556
+ if not host or not remote_path or not local_path:
557
+ continue
558
+
559
+ spec = mount_sshfs(
560
+ host=host,
561
+ remote_path=remote_path,
562
+ local_path=local_path,
563
+ user=entry.get("user"),
564
+ key_path=entry.get("key_path"),
565
+ port=int(entry.get("port", 22)),
566
+ )
567
+ if isinstance(spec, dict) and spec.get("success"):
568
+ results.append(spec)
569
+
570
+ return results
571
+
572
+ return []
573
+
574
+
575
+ # =============================================================================
576
+ # SSH Policy Manager
577
+ # =============================================================================
578
+
579
+
580
+ # Default path for approved hosts state file
581
+ _SSH_APPROVED_HOSTS_PATH = os.path.join(".omg", "state", "ssh_approved_hosts.json")
582
+
583
+
584
+ class SSHPolicyManager:
585
+ """Manages SSH host approval policy and fingerprint verification.
586
+
587
+ Reads/writes approved hosts from `.omg/state/ssh_approved_hosts.json`.
588
+ Integrates with the policy_engine pattern for SSH-specific checks.
589
+ """
590
+
591
+ def __init__(self, state_path: Optional[str] = None):
592
+ """Initialize with optional custom state path."""
593
+ self._state_path = state_path or _SSH_APPROVED_HOSTS_PATH
594
+
595
+ def _load_approved_hosts(self) -> List[Dict[str, Any]]:
596
+ """Load approved hosts from state file."""
597
+ if not os.path.isfile(self._state_path):
598
+ return []
599
+ try:
600
+ with open(self._state_path, "r", encoding="utf-8") as f:
601
+ data = json.load(f)
602
+ if isinstance(data, dict):
603
+ hosts = data.get("hosts", [])
604
+ return hosts if isinstance(hosts, list) else []
605
+ return []
606
+ except (json.JSONDecodeError, OSError):
607
+ return []
608
+
609
+ def _save_approved_hosts(self, hosts: List[Dict[str, Any]]) -> None:
610
+ """Save approved hosts to state file using atomic write."""
611
+ _ensure_imports()
612
+ payload = {"hosts": hosts}
613
+ if _atomic_json_write is not None:
614
+ _atomic_json_write(self._state_path, payload)
615
+ else:
616
+ # Fallback: direct write with parent dir creation
617
+ parent = os.path.dirname(self._state_path)
618
+ if parent:
619
+ os.makedirs(parent, exist_ok=True)
620
+ with open(self._state_path, "w", encoding="utf-8") as f:
621
+ json.dump(payload, f, separators=(",", ":"))
622
+
623
+ def is_host_approved(self, host: str, port: int = 22) -> bool:
624
+ """Check if a host:port is in the approved hosts list.
625
+
626
+ Args:
627
+ host: Hostname or IP address.
628
+ port: SSH port (default: 22).
629
+
630
+ Returns:
631
+ True if the host:port is approved, False otherwise.
632
+ """
633
+ if not host:
634
+ return False
635
+ hosts = self._load_approved_hosts()
636
+ for entry in hosts:
637
+ if isinstance(entry, dict):
638
+ if entry.get("host") == host and int(entry.get("port", 22)) == port:
639
+ return True
640
+ return False
641
+
642
+ def approve_host(self, host: str, port: int = 22, fingerprint: Optional[str] = None) -> bool:
643
+ """Add a host to the approved hosts list.
644
+
645
+ Args:
646
+ host: Hostname or IP address.
647
+ port: SSH port (default: 22).
648
+ fingerprint: Optional SSH host fingerprint.
649
+
650
+ Returns:
651
+ True if the host was added (or already existed), False on error.
652
+ """
653
+ if not host or not isinstance(host, str):
654
+ return False
655
+
656
+ hosts = self._load_approved_hosts()
657
+
658
+ # Check if already approved
659
+ for entry in hosts:
660
+ if isinstance(entry, dict):
661
+ if entry.get("host") == host and int(entry.get("port", 22)) == port:
662
+ # Update fingerprint if provided
663
+ if fingerprint:
664
+ entry["fingerprint"] = fingerprint
665
+ self._save_approved_hosts(hosts)
666
+ return True
667
+
668
+ # Add new entry
669
+ entry = {
670
+ "host": host,
671
+ "port": port,
672
+ "fingerprint": fingerprint,
673
+ "approved_at": datetime.now(timezone.utc).isoformat(),
674
+ }
675
+ hosts.append(entry)
676
+ self._save_approved_hosts(hosts)
677
+ return True
678
+
679
+ def revoke_host(self, host: str, port: int = 22) -> bool:
680
+ """Remove a host from the approved hosts list.
681
+
682
+ Args:
683
+ host: Hostname or IP address.
684
+ port: SSH port (default: 22).
685
+
686
+ Returns:
687
+ True if the host was found and removed, False otherwise.
688
+ """
689
+ if not host:
690
+ return False
691
+ hosts = self._load_approved_hosts()
692
+ original_len = len(hosts)
693
+ hosts = [
694
+ e for e in hosts
695
+ if not (isinstance(e, dict) and e.get("host") == host and int(e.get("port", 22)) == port)
696
+ ]
697
+ if len(hosts) < original_len:
698
+ self._save_approved_hosts(hosts)
699
+ return True
700
+ return False
701
+
702
+ def verify_fingerprint(self, host: str, expected_fingerprint: str, actual_fingerprint: str) -> bool:
703
+ """Compare an expected fingerprint against an actual fingerprint.
704
+
705
+ Args:
706
+ host: Hostname (for context/logging).
707
+ expected_fingerprint: The trusted fingerprint on file.
708
+ actual_fingerprint: The fingerprint received from the host.
709
+
710
+ Returns:
711
+ True if fingerprints match, False otherwise.
712
+ """
713
+ if not expected_fingerprint or not actual_fingerprint:
714
+ return False
715
+ return expected_fingerprint.strip() == actual_fingerprint.strip()
716
+
717
+ def get_approved_hosts(self) -> List[Dict[str, Any]]:
718
+ """Return all approved hosts.
719
+
720
+ Returns:
721
+ List of approved host dicts.
722
+ """
723
+ return self._load_approved_hosts()
724
+
725
+ def requires_approval(self, host: str, port: int = 22) -> Dict[str, Any]:
726
+ """Check if a host requires approval before connecting.
727
+
728
+ Args:
729
+ host: Hostname or IP address.
730
+ port: SSH port (default: 22).
731
+
732
+ Returns:
733
+ Dict with requires_approval bool and reason string.
734
+ """
735
+ if not host:
736
+ return {"requires_approval": True, "reason": "Empty host"}
737
+ if self.is_host_approved(host, port):
738
+ return {"requires_approval": False, "reason": "Host is approved"}
739
+ return {
740
+ "requires_approval": True,
741
+ "reason": f"Host {host}:{port} is not in the approved hosts list",
742
+ }
743
+
744
+
745
+ def _check_ssh_policy(host: str, port: int = 22) -> Dict[str, Any]:
746
+ """Check SSH policy for a host connection attempt.
747
+
748
+ Returns:
749
+ Dict with allowed bool, reason string, and fingerprint_required bool.
750
+ """
751
+ if not host:
752
+ return {"allowed": False, "reason": "Empty host", "fingerprint_required": False}
753
+
754
+ approval = _ssh_policy.requires_approval(host, port)
755
+ if approval["requires_approval"]:
756
+ # Check if the host has a stored fingerprint requirement
757
+ hosts = _ssh_policy.get_approved_hosts()
758
+ fingerprint_required = False
759
+ for entry in hosts:
760
+ if isinstance(entry, dict) and entry.get("host") == host:
761
+ if entry.get("fingerprint"):
762
+ fingerprint_required = True
763
+ break
764
+ return {
765
+ "allowed": False,
766
+ "reason": approval["reason"],
767
+ "fingerprint_required": fingerprint_required,
768
+ }
769
+
770
+ return {"allowed": True, "reason": "Host approved", "fingerprint_required": False}
771
+
772
+
773
+ # Module-level singleton
774
+ _ssh_policy = SSHPolicyManager()
775
+
776
+
777
+ # =============================================================================
778
+ # CLI Interface
779
+ # =============================================================================
780
+
781
+
782
+ def _cli_main():
783
+ """CLI entry point for ssh_manager.py."""
784
+ import argparse
785
+
786
+ parser = argparse.ArgumentParser(
787
+ description="OMG SSH Connection Manager — SSH connection spec management",
788
+ formatter_class=argparse.RawDescriptionHelpFormatter,
789
+ )
790
+ parser.add_argument(
791
+ "--discover", action="store_true",
792
+ help="Discover SSH hosts from ssh.json or .ssh.json",
793
+ )
794
+ parser.add_argument(
795
+ "--project-dir", default=".",
796
+ help="Project directory to search for config (default: .)",
797
+ )
798
+ parser.add_argument(
799
+ "--connect", dest="connect_host",
800
+ help="Create a connection spec for HOST",
801
+ )
802
+ parser.add_argument("--port", type=int, default=22, help="SSH port (default: 22)")
803
+ parser.add_argument("--user", help="SSH username")
804
+ parser.add_argument("--key-path", dest="key_path", help="Path to SSH private key")
805
+ parser.add_argument(
806
+ "--list-connections", action="store_true",
807
+ help="List active connections in the pool",
808
+ )
809
+ parser.add_argument(
810
+ "--disconnect", dest="disconnect_host",
811
+ help="Remove a connection from the pool",
812
+ )
813
+ parser.add_argument(
814
+ "--detect-os", action="store_true",
815
+ help="Detect OS type (use with --connect)",
816
+ )
817
+ parser.add_argument(
818
+ "--detect-shell", action="store_true",
819
+ help="Detect shell type (use with --connect)",
820
+ )
821
+ parser.add_argument(
822
+ "--dry-run", action="store_true",
823
+ help="Print what would happen without making changes",
824
+ )
825
+
826
+ args = parser.parse_args()
827
+
828
+ enabled = _is_enabled()
829
+
830
+ # Discover hosts
831
+ if args.discover:
832
+ if args.dry_run:
833
+ print(json.dumps({
834
+ "dry_run": True,
835
+ "operation": "discover",
836
+ "project_dir": os.path.abspath(args.project_dir),
837
+ "enabled": enabled,
838
+ }, indent=2))
839
+ return
840
+
841
+ if not enabled:
842
+ print(json.dumps({
843
+ "error": "SSH feature is disabled (OMG_SSH_ENABLED=false)",
844
+ }))
845
+ sys.exit(1)
846
+
847
+ hosts = discover_hosts(args.project_dir)
848
+ output = [h.to_dict() for h in hosts]
849
+ print(json.dumps({
850
+ "hosts": output,
851
+ "count": len(output),
852
+ "project_dir": os.path.abspath(args.project_dir),
853
+ }, indent=2))
854
+ return
855
+
856
+ # Connect
857
+ if args.connect_host:
858
+ if not enabled:
859
+ print(json.dumps({
860
+ "error": "SSH feature is disabled (OMG_SSH_ENABLED=false)",
861
+ }))
862
+ sys.exit(1)
863
+
864
+ result = connect(
865
+ host=args.connect_host,
866
+ port=args.port,
867
+ user=args.user,
868
+ key_path=args.key_path,
869
+ )
870
+ print(json.dumps(result, indent=2))
871
+
872
+ if args.detect_os or args.detect_shell:
873
+ conn = result.get("result", {})
874
+ info = {}
875
+ if args.detect_os:
876
+ info["os_type"] = detect_os(conn)
877
+ if args.detect_shell:
878
+ info["shell"] = detect_shell(conn)
879
+ print(json.dumps(info, indent=2))
880
+ return
881
+
882
+ # List connections
883
+ if args.list_connections:
884
+ conns = get_connections()
885
+ print(json.dumps({
886
+ "connections": conns,
887
+ "count": len(conns),
888
+ "enabled": enabled,
889
+ }, indent=2))
890
+ return
891
+
892
+ # Disconnect
893
+ if args.disconnect_host:
894
+ if not enabled:
895
+ print(json.dumps({
896
+ "error": "SSH feature is disabled (OMG_SSH_ENABLED=false)",
897
+ }))
898
+ sys.exit(1)
899
+
900
+ removed = disconnect(args.disconnect_host, args.port)
901
+ print(json.dumps({
902
+ "disconnected": removed,
903
+ "host": args.disconnect_host,
904
+ "port": args.port,
905
+ }, indent=2))
906
+ return
907
+
908
+ parser.print_help()
909
+
910
+
911
+ if __name__ == "__main__":
912
+ _cli_main()