github2gerrit 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

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.
@@ -0,0 +1,351 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ SSH agent-based authentication for github2gerrit.
6
+
7
+ This module provides functionality to use SSH agent for authentication
8
+ instead of writing private keys to disk, which is more secure and
9
+ avoids file permission issues in CI environments.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ from .gitutils import CommandError
21
+ from .gitutils import run_cmd
22
+ from .ssh_common import augment_known_hosts_with_bracketed_entries
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ class SSHAgentError(Exception):
29
+ """Raised when SSH agent operations fail."""
30
+
31
+
32
+ # Error message constants to comply with TRY003
33
+ _MSG_PARSE_FAILED = "Failed to parse ssh-agent output"
34
+ _MSG_START_FAILED = "Failed to start SSH agent: {error}"
35
+ _MSG_NOT_STARTED = "SSH agent not started"
36
+ _MSG_ADD_FAILED = "ssh-add failed: {error}"
37
+ _MSG_ADD_TIMEOUT = "ssh-add timed out"
38
+ _MSG_ADD_KEY_FAILED = "Failed to add key to SSH agent: {error}"
39
+ _MSG_SETUP_HOSTS_FAILED = "Failed to setup known hosts: {error}"
40
+ _MSG_HOSTS_NOT_CONFIGURED = "Known hosts not configured"
41
+ _MSG_LIST_FAILED = "Failed to list keys: {error}"
42
+ _MSG_NO_KEYS_LOADED = "No keys were loaded into SSH agent"
43
+ _MSG_SSH_AGENT_NOT_FOUND = "ssh-agent not found in PATH"
44
+ _MSG_SSH_ADD_NOT_FOUND = "ssh-add not found in PATH"
45
+
46
+
47
+ class SSHAgentManager:
48
+ """Manages SSH agent lifecycle and key loading for secure authentication."""
49
+
50
+ def __init__(self, workspace: Path):
51
+ """Initialize SSH agent manager.
52
+
53
+ Args:
54
+ workspace: The workspace directory for storing temporary files
55
+ """
56
+ self.workspace = workspace
57
+ self.agent_pid: int | None = None
58
+ self.auth_sock: str | None = None
59
+ self.known_hosts_path: Path | None = None
60
+ self._original_env: dict[str, str] = {}
61
+
62
+ def start_agent(self) -> None:
63
+ """Start a new SSH agent process."""
64
+ try:
65
+ # Locate ssh-agent executable
66
+ ssh_agent_path = shutil.which("ssh-agent")
67
+ if not ssh_agent_path:
68
+ _raise_ssh_agent_not_found()
69
+ assert ssh_agent_path is not None # for mypy # noqa: S101
70
+
71
+ # Start ssh-agent and capture its output
72
+ result = run_cmd([ssh_agent_path, "-s"], timeout=10)
73
+
74
+ # Parse the ssh-agent output to get environment variables
75
+ for line in result.stdout.strip().split("\n"):
76
+ if line.startswith("SSH_AUTH_SOCK="):
77
+ # Format: SSH_AUTH_SOCK=/path/to/socket; export SSH_AUTH_SOCK;
78
+ value = line.split("=", 1)[1].split(";")[0].strip()
79
+ self.auth_sock = value
80
+ elif line.startswith("SSH_AGENT_PID="):
81
+ # Format: SSH_AGENT_PID=12345; export SSH_AGENT_PID;
82
+ value = line.split("=", 1)[1].split(";")[0].strip()
83
+ self.agent_pid = int(value)
84
+
85
+ if not self.auth_sock or not self.agent_pid:
86
+ _raise_parse_error()
87
+
88
+ # Store original environment
89
+ self._original_env = {
90
+ "SSH_AUTH_SOCK": os.environ.get("SSH_AUTH_SOCK", ""),
91
+ "SSH_AGENT_PID": os.environ.get("SSH_AGENT_PID", ""),
92
+ }
93
+
94
+ # Set environment variables for this process
95
+ if self.auth_sock:
96
+ os.environ["SSH_AUTH_SOCK"] = self.auth_sock
97
+ if self.agent_pid:
98
+ os.environ["SSH_AGENT_PID"] = str(self.agent_pid)
99
+
100
+ log.debug("Started SSH agent with PID %d, socket %s", self.agent_pid, self.auth_sock)
101
+
102
+ except Exception as exc:
103
+ raise SSHAgentError(_MSG_START_FAILED.format(error=exc)) from exc
104
+
105
+ def add_key(self, private_key_content: str) -> None:
106
+ """Add a private key to the SSH agent.
107
+
108
+ Args:
109
+ private_key_content: The private key content as a string
110
+ """
111
+ if not self.auth_sock:
112
+ raise SSHAgentError(_MSG_NOT_STARTED)
113
+
114
+ # Locate ssh-add executable
115
+ ssh_add_path = shutil.which("ssh-add")
116
+ if not ssh_add_path:
117
+ _raise_ssh_add_not_found()
118
+ assert ssh_add_path is not None # for mypy # noqa: S101
119
+
120
+ process = None
121
+ try:
122
+ # Use ssh-add with stdin to add the key
123
+ process = subprocess.Popen( # noqa: S603
124
+ [ssh_add_path, "-"],
125
+ stdin=subprocess.PIPE,
126
+ stdout=subprocess.PIPE,
127
+ stderr=subprocess.PIPE,
128
+ text=True,
129
+ env={
130
+ **os.environ,
131
+ "SSH_AUTH_SOCK": self.auth_sock,
132
+ "SSH_AGENT_PID": str(self.agent_pid),
133
+ },
134
+ )
135
+
136
+ stdout, stderr = process.communicate(input=private_key_content.strip() + "\n", timeout=10)
137
+
138
+ if process.returncode != 0:
139
+ _raise_add_key_error(stderr)
140
+
141
+ log.debug("Successfully added SSH key to agent")
142
+
143
+ except subprocess.TimeoutExpired as exc:
144
+ if process:
145
+ process.kill()
146
+ raise SSHAgentError(_MSG_ADD_TIMEOUT) from exc
147
+ except Exception as exc:
148
+ raise SSHAgentError(_MSG_ADD_KEY_FAILED.format(error=exc)) from exc
149
+
150
+ def setup_known_hosts(self, known_hosts_content: str) -> None:
151
+ """Setup known hosts file.
152
+
153
+ Args:
154
+ known_hosts_content: The known hosts content
155
+ """
156
+ try:
157
+ # Create tool-specific SSH directory
158
+ tool_ssh_dir = self.workspace / ".ssh-g2g"
159
+ tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
160
+
161
+ # Write known hosts file (normalize/augment with [host]:port entries)
162
+ self.known_hosts_path = tool_ssh_dir / "known_hosts"
163
+ host = (os.getenv("GERRIT_SERVER") or "").strip()
164
+ port = (os.getenv("GERRIT_SERVER_PORT") or "29418").strip()
165
+ try:
166
+ port_int = int(port)
167
+ except Exception:
168
+ port_int = 29418
169
+
170
+ # Use centralized augmentation logic
171
+ augmented_content = augment_known_hosts_with_bracketed_entries(known_hosts_content, host, port_int)
172
+
173
+ with open(self.known_hosts_path, "w", encoding="utf-8") as f:
174
+ f.write(augmented_content)
175
+ self.known_hosts_path.chmod(0o644)
176
+
177
+ log.debug("Known hosts written to %s", self.known_hosts_path)
178
+
179
+ except Exception as exc:
180
+ raise SSHAgentError(_MSG_SETUP_HOSTS_FAILED.format(error=exc)) from exc
181
+
182
+ def get_git_ssh_command(self) -> str:
183
+ """Generate GIT_SSH_COMMAND for SSH agent-based authentication.
184
+
185
+ Returns:
186
+ SSH command string for git operations
187
+ """
188
+ if not self.known_hosts_path:
189
+ raise SSHAgentError(_MSG_HOSTS_NOT_CONFIGURED)
190
+
191
+ ssh_options = [
192
+ "-F /dev/null",
193
+ f"-o UserKnownHostsFile={self.known_hosts_path}",
194
+ "-o IdentitiesOnly=no", # Allow SSH agent
195
+ "-o BatchMode=yes",
196
+ "-o PreferredAuthentications=publickey",
197
+ "-o StrictHostKeyChecking=yes",
198
+ "-o PasswordAuthentication=no",
199
+ "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
200
+ "-o ConnectTimeout=10",
201
+ ]
202
+
203
+ return f"ssh {' '.join(ssh_options)}"
204
+
205
+ def get_ssh_env(self) -> dict[str, str]:
206
+ """Get environment variables for SSH operations.
207
+
208
+ Returns:
209
+ Dictionary of environment variables
210
+ """
211
+ if not self.auth_sock:
212
+ raise SSHAgentError(_MSG_NOT_STARTED)
213
+
214
+ return {
215
+ "SSH_AUTH_SOCK": self.auth_sock,
216
+ "SSH_AGENT_PID": str(self.agent_pid),
217
+ }
218
+
219
+ def list_keys(self) -> str:
220
+ """List keys currently loaded in the agent.
221
+
222
+ Returns:
223
+ Output from ssh-add -l
224
+ """
225
+ if not self.auth_sock:
226
+ raise SSHAgentError(_MSG_NOT_STARTED)
227
+
228
+ try:
229
+ # Locate ssh-add executable
230
+ ssh_add_path = shutil.which("ssh-add")
231
+ if not ssh_add_path:
232
+ _raise_ssh_add_not_found()
233
+ assert ssh_add_path is not None # for mypy # noqa: S101
234
+
235
+ result = run_cmd(
236
+ [ssh_add_path, "-l"],
237
+ env={
238
+ **os.environ,
239
+ "SSH_AUTH_SOCK": self.auth_sock,
240
+ "SSH_AGENT_PID": str(self.agent_pid),
241
+ },
242
+ timeout=5,
243
+ )
244
+ except CommandError as exc:
245
+ if exc.returncode == 1:
246
+ return "No keys loaded"
247
+ raise SSHAgentError(_MSG_LIST_FAILED.format(error=exc)) from exc
248
+ except Exception as exc:
249
+ raise SSHAgentError(_MSG_LIST_FAILED.format(error=exc)) from exc
250
+ else:
251
+ return result.stdout
252
+
253
+ def cleanup(self) -> None:
254
+ """Clean up SSH agent and temporary files."""
255
+ try:
256
+ # Kill SSH agent if we started it
257
+ if self.agent_pid:
258
+ try:
259
+ run_cmd(["/bin/kill", str(self.agent_pid)], timeout=5)
260
+ log.debug("SSH agent (PID %d) terminated", self.agent_pid)
261
+ except Exception as exc:
262
+ log.warning("Failed to kill SSH agent: %s", exc)
263
+
264
+ # Restore original environment
265
+ for key, value in self._original_env.items():
266
+ if value:
267
+ os.environ[key] = value
268
+ elif key in os.environ:
269
+ del os.environ[key]
270
+
271
+ # Clean up temporary files
272
+ tool_ssh_dir = self.workspace / ".ssh-g2g"
273
+ if tool_ssh_dir.exists():
274
+ import shutil
275
+
276
+ shutil.rmtree(tool_ssh_dir)
277
+ log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
278
+
279
+ except Exception as exc:
280
+ log.warning("Failed to clean up SSH agent: %s", exc)
281
+ finally:
282
+ self.agent_pid = None
283
+ self.auth_sock = None
284
+ self.known_hosts_path = None
285
+
286
+
287
+ def setup_ssh_agent_auth(workspace: Path, private_key_content: str, known_hosts_content: str) -> SSHAgentManager:
288
+ """Setup SSH agent-based authentication.
289
+
290
+ Args:
291
+ workspace: The workspace directory
292
+ private_key_content: SSH private key content
293
+ known_hosts_content: Known hosts content
294
+
295
+ Returns:
296
+ Configured SSHAgentManager instance
297
+
298
+ Raises:
299
+ SSHAgentError: If setup fails
300
+ """
301
+ manager = SSHAgentManager(workspace)
302
+
303
+ try:
304
+ # Start SSH agent
305
+ manager.start_agent()
306
+
307
+ # Add the private key
308
+ manager.add_key(private_key_content)
309
+
310
+ # Setup known hosts
311
+ manager.setup_known_hosts(known_hosts_content)
312
+
313
+ # Verify key was added
314
+ keys_list = manager.list_keys()
315
+ if "No keys loaded" in keys_list:
316
+ _raise_no_keys_error()
317
+
318
+ log.info("SSH agent authentication configured successfully")
319
+ log.debug("Loaded keys: %s", keys_list)
320
+
321
+ except Exception:
322
+ # Clean up on failure
323
+ manager.cleanup()
324
+ raise
325
+ else:
326
+ return manager
327
+
328
+
329
+ def _raise_parse_error() -> None:
330
+ """Raise SSH agent parse error."""
331
+ raise SSHAgentError(_MSG_PARSE_FAILED)
332
+
333
+
334
+ def _raise_add_key_error(stderr: str) -> None:
335
+ """Raise SSH key addition error."""
336
+ raise SSHAgentError(_MSG_ADD_FAILED.format(error=stderr))
337
+
338
+
339
+ def _raise_ssh_agent_not_found() -> None:
340
+ """Raise SSH agent not found error."""
341
+ raise SSHAgentError(_MSG_SSH_AGENT_NOT_FOUND)
342
+
343
+
344
+ def _raise_ssh_add_not_found() -> None:
345
+ """Raise SSH add not found error."""
346
+ raise SSHAgentError(_MSG_SSH_ADD_NOT_FOUND)
347
+
348
+
349
+ def _raise_no_keys_error() -> None:
350
+ """Raise no keys loaded error."""
351
+ raise SSHAgentError(_MSG_NO_KEYS_LOADED)
@@ -0,0 +1,244 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """Common SSH utilities for consistent SSH configuration across modules.
5
+
6
+ This module provides shared SSH functionality to avoid duplication between
7
+ CLI and core SSH setup routines, ensuring consistent behavior and options.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def build_ssh_options(
19
+ key_path: str | Path | None = None,
20
+ known_hosts_path: str | Path | None = None,
21
+ identities_only: bool = True,
22
+ strict_host_checking: bool = True,
23
+ batch_mode: bool = True,
24
+ connect_timeout: int = 10,
25
+ additional_options: list[str] | None = None,
26
+ respect_user_ssh_config: bool | None = None,
27
+ ) -> list[str]:
28
+ """Build a list of SSH options for secure, isolated SSH configuration.
29
+
30
+ Args:
31
+ key_path: Path to SSH private key file
32
+ known_hosts_path: Path to known_hosts file
33
+ identities_only: Only use specified identities (prevents agent scanning)
34
+ strict_host_checking: Enable strict host key checking
35
+ batch_mode: Enable batch mode (non-interactive)
36
+ connect_timeout: Connection timeout in seconds
37
+ additional_options: Additional SSH options to include
38
+ respect_user_ssh_config: If True, respect user SSH config; if None, check G2G_RESPECT_USER_SSH env var
39
+
40
+ Returns:
41
+ List of SSH option strings suitable for ssh command line
42
+ """
43
+ # Check if we should respect user SSH config
44
+ if respect_user_ssh_config is None:
45
+ respect_user_ssh_config = os.getenv("G2G_RESPECT_USER_SSH", "false").lower() in ("true", "1", "yes")
46
+
47
+ options = []
48
+ if not respect_user_ssh_config:
49
+ options.append("-F /dev/null") # Ignore user SSH config
50
+
51
+ if key_path:
52
+ options.append(f"-i {key_path}")
53
+
54
+ if known_hosts_path:
55
+ options.append(f"-o UserKnownHostsFile={known_hosts_path}")
56
+
57
+ if identities_only and not respect_user_ssh_config:
58
+ options.extend(
59
+ [
60
+ "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
61
+ "-o IdentityAgent=none",
62
+ ]
63
+ )
64
+
65
+ if batch_mode:
66
+ options.append("-o BatchMode=yes")
67
+
68
+ options.extend(
69
+ [
70
+ "-o PreferredAuthentications=publickey",
71
+ "-o PasswordAuthentication=no",
72
+ "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
73
+ f"-o ConnectTimeout={connect_timeout}",
74
+ ]
75
+ )
76
+
77
+ if strict_host_checking:
78
+ options.append("-o StrictHostKeyChecking=yes")
79
+
80
+ if additional_options:
81
+ options.extend(additional_options)
82
+
83
+ return options
84
+
85
+
86
+ def build_git_ssh_command(
87
+ key_path: str | Path | None = None,
88
+ known_hosts_path: str | Path | None = None,
89
+ identities_only: bool = True,
90
+ strict_host_checking: bool = True,
91
+ batch_mode: bool = True,
92
+ connect_timeout: int = 10,
93
+ additional_options: list[str] | None = None,
94
+ respect_user_ssh_config: bool | None = None,
95
+ ) -> str:
96
+ """Build GIT_SSH_COMMAND for secure, isolated SSH configuration.
97
+
98
+ This prevents SSH from scanning the user's SSH agent or using
99
+ unintended keys by setting appropriate SSH options, unless
100
+ respect_user_ssh_config is True.
101
+
102
+ Args:
103
+ key_path: Path to SSH private key file
104
+ known_hosts_path: Path to known_hosts file
105
+ identities_only: Only use specified identities (prevents agent scanning)
106
+ strict_host_checking: Enable strict host key checking
107
+ batch_mode: Enable batch mode (non-interactive)
108
+ connect_timeout: Connection timeout in seconds
109
+ additional_options: Additional SSH options to include
110
+ respect_user_ssh_config: If True, respect user SSH config; if None, check G2G_RESPECT_USER_SSH env var
111
+
112
+ Returns:
113
+ Complete SSH command string suitable for GIT_SSH_COMMAND
114
+ """
115
+ ssh_options = build_ssh_options(
116
+ key_path=key_path,
117
+ known_hosts_path=known_hosts_path,
118
+ identities_only=identities_only,
119
+ strict_host_checking=strict_host_checking,
120
+ batch_mode=batch_mode,
121
+ connect_timeout=connect_timeout,
122
+ additional_options=additional_options,
123
+ respect_user_ssh_config=respect_user_ssh_config,
124
+ )
125
+
126
+ ssh_cmd = f"ssh {' '.join(ssh_options)}"
127
+
128
+ # Log masked version for security
129
+ if key_path:
130
+ masked_cmd = ssh_cmd.replace(str(key_path), "[KEY_PATH]")
131
+ log.debug("Generated SSH command: %s", masked_cmd)
132
+ else:
133
+ log.debug("Generated SSH command: %s", ssh_cmd)
134
+
135
+ return ssh_cmd
136
+
137
+
138
+ def build_non_interactive_ssh_env() -> dict[str, str]:
139
+ """Build environment variables for non-interactive SSH operations.
140
+
141
+ This creates an environment that prevents SSH from using agents,
142
+ asking for passwords, or displaying prompts.
143
+
144
+ Returns:
145
+ Dictionary of environment variables for non-interactive SSH
146
+ """
147
+ return {
148
+ "SSH_AUTH_SOCK": "",
149
+ "SSH_AGENT_PID": "",
150
+ "SSH_ASKPASS": "/usr/bin/false",
151
+ "DISPLAY": "",
152
+ "SSH_ASKPASS_REQUIRE": "never",
153
+ }
154
+
155
+
156
+ def augment_known_hosts_with_bracketed_entries(
157
+ known_hosts_content: str,
158
+ hostname: str,
159
+ port: int = 22,
160
+ ) -> str:
161
+ """Augment known_hosts content with bracketed [host]:port entries for non-standard ports.
162
+
163
+ This function adds bracketed [host]:port variants for existing plain host entries
164
+ to satisfy StrictHostKeyChecking with non-standard SSH ports.
165
+
166
+ Args:
167
+ known_hosts_content: Original known_hosts content
168
+ hostname: Hostname to augment
169
+ port: SSH port (default 22)
170
+
171
+ Returns:
172
+ Augmented known_hosts content (always normalized to end with single newline)
173
+ """
174
+ if not known_hosts_content.strip():
175
+ return known_hosts_content
176
+
177
+ original_lines = [ln.rstrip() for ln in known_hosts_content.strip().splitlines() if ln.strip()]
178
+ augmented = list(original_lines)
179
+
180
+ # Add bracketed [host]:port variants for non-standard ports if hostname provided
181
+ if hostname and original_lines:
182
+ bracket_prefix = f"[{hostname}]:{port} "
183
+ plain_prefix = f"{hostname} "
184
+ existing = set(augmented)
185
+
186
+ for ln in original_lines:
187
+ if ln.startswith(plain_prefix):
188
+ suffix = ln[len(plain_prefix) :]
189
+ candidate = bracket_prefix + suffix
190
+ if candidate not in existing:
191
+ augmented.append(candidate)
192
+ existing.add(candidate)
193
+
194
+ # Always normalize to strip + newline for consistency
195
+ return "\n".join(augmented).strip() + "\n"
196
+
197
+
198
+ def merge_known_hosts_content(
199
+ base_content: str,
200
+ additional_content: str,
201
+ ) -> str:
202
+ """Merge additional known_hosts content into base content without duplicates.
203
+
204
+ Args:
205
+ base_content: Original known_hosts content
206
+ additional_content: Additional content to merge
207
+
208
+ Returns:
209
+ Merged known_hosts content
210
+ """
211
+ if not additional_content or not additional_content.strip():
212
+ return base_content
213
+
214
+ if not base_content or not base_content.strip():
215
+ return additional_content.strip() + "\n"
216
+
217
+ existing_lines = [ln for ln in base_content.splitlines() if ln.strip()]
218
+ existing_set = set(existing_lines)
219
+
220
+ for ln in additional_content.splitlines():
221
+ s = ln.strip()
222
+ if s and s not in existing_set:
223
+ existing_lines.append(s)
224
+ existing_set.add(s)
225
+
226
+ return "\n".join(existing_lines).strip() + "\n"
227
+
228
+
229
+ def augment_known_hosts(
230
+ known_hosts_path: Path,
231
+ hostname: str,
232
+ port: int = 22,
233
+ ) -> None:
234
+ """Augment known_hosts file with host key for the given hostname.
235
+
236
+ Args:
237
+ known_hosts_path: Path to known_hosts file
238
+ hostname: Hostname to add to known_hosts
239
+ port: SSH port (default 22)
240
+ """
241
+ # This is a placeholder for known hosts augmentation logic
242
+ # The actual implementation would use ssh-keyscan or similar
243
+ # to fetch and add host keys to the known_hosts file
244
+ log.debug("Would augment known_hosts at %s with %s:%d", known_hosts_path, hostname, port)
@@ -16,6 +16,8 @@ import os
16
16
  import socket
17
17
  from pathlib import Path
18
18
 
19
+ from .external_api import ApiType
20
+ from .external_api import external_api_call
19
21
  from .gitutils import CommandError
20
22
  from .gitutils import run_cmd
21
23
 
@@ -50,6 +52,7 @@ def is_host_reachable(hostname: str, port: int, timeout: int = 5) -> bool:
50
52
  return False
51
53
 
52
54
 
55
+ @external_api_call(ApiType.SSH, "fetch_ssh_host_keys")
53
56
  def fetch_ssh_host_keys(hostname: str, port: int = 22, timeout: int = 10) -> str:
54
57
  """
55
58
  Fetch SSH host keys for a given hostname and port using ssh-keyscan.
@@ -168,6 +171,7 @@ def extract_gerrit_info_from_gitreview(content: str) -> tuple[str, int] | None:
168
171
  return (hostname, port) if hostname else None
169
172
 
170
173
 
174
+ @external_api_call(ApiType.SSH, "discover_and_save_host_keys")
171
175
  def discover_and_save_host_keys(hostname: str, port: int, organization: str, config_path: str | None = None) -> str:
172
176
  """
173
177
  Discover SSH host keys and save them to the organization's configuration.