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