hermes-ssh 0.3.1__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,273 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-ssh
3
+ Version: 0.3.1
4
+ Summary: SSH remote execution plugin for Hermes Agent
5
+ Project-URL: Homepage, https://github.com/TheEpTic/hermes-plugins
6
+ Project-URL: Repository, https://github.com/TheEpTic/hermes-plugins
7
+ Project-URL: Issues, https://github.com/TheEpTic/hermes-plugins/issues
8
+ Project-URL: Changelog, https://github.com/TheEpTic/hermes-plugins/blob/main/hermes-ssh/CHANGELOG.md
9
+ Author-email: TheEpTic <nexus@eptic.me>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,devops,hermes,remote,ssh
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: cryptography<49,>=48.0.1
24
+ Provides-Extra: dev
25
+ Requires-Dist: black==26.5.1; extra == 'dev'
26
+ Requires-Dist: mypy==1.16.0; extra == 'dev'
27
+ Requires-Dist: pytest==9.0.3; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # hermes-ssh
31
+
32
+ [![CI](https://github.com/TheEpTic/hermes-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/TheEpTic/hermes-plugins/actions/workflows/ci.yml)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
34
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
35
+
36
+ SSH remote execution plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
37
+
38
+ Run commands on remote servers, track sessions, reuse connections — all from inside Hermes.
39
+
40
+ ```
41
+ /ssh web1 uptime
42
+ ssh_machines add name=web1 host=192.168.1.50
43
+ ssh_sessions list
44
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ > **Requires Python 3.11+** and an OpenSSH client (`ssh`) on the host system.
49
+
50
+ ### Option 1: Deploy script (recommended)
51
+
52
+ ```bash
53
+ git clone https://github.com/TheEpTic/hermes-plugins.git
54
+ cd hermes-plugins/hermes-ssh
55
+ ./deploy.sh
56
+ ```
57
+
58
+ Then restart Hermes with `/reset`.
59
+
60
+ ### Option 2: Manual symlink
61
+
62
+ ```bash
63
+ git clone https://github.com/TheEpTic/hermes-plugins.git
64
+ ln -s "$(pwd)/hermes-plugins/hermes-ssh/src/ssh_tools" ~/.hermes/plugins/hermes-ssh
65
+ ```
66
+
67
+ Then `/reset` in Hermes. The symlink points at the source tree, but Python modules are imported once, so code changes still require `/reset` or a Hermes process restart before they load.
68
+
69
+ ### Option 3: As a Python package
70
+
71
+ ```bash
72
+ pip install git+https://github.com/TheEpTic/hermes-plugins.git#subdirectory=hermes-ssh
73
+ ```
74
+
75
+ Then enable it and restart Hermes:
76
+
77
+ ```bash
78
+ hermes plugins enable hermes-ssh
79
+ ```
80
+
81
+ ## Features
82
+
83
+ ### `ssh_terminal` — Run Commands
84
+
85
+ Execute any command on a remote machine. Commands run through `bash -c` with `pipefail`, so pipelines work correctly.
86
+
87
+ ```bash
88
+ # Synchronous (waits for completion)
89
+ ssh_terminal machine=web1 command="df -h"
90
+
91
+ # Background (returns immediately)
92
+ ssh_terminal machine=web1 command="tail -f /var/log/syslog" background=true
93
+
94
+ # With timeout
95
+ ssh_terminal machine=web1 command="make -j4" timeout=300
96
+ ```
97
+
98
+ **Output truncation:** When output exceeds `max_output_chars` (default: 50,000), the full output is saved under the plugin's restricted output directory and a summary with the file path is returned. The LLM can then use `read_file` to access the complete output.
99
+
100
+ **Background commands:** Long-running commands can be backgrounded. The plugin tracks the process and lets you poll for status or retrieve output later.
101
+
102
+ ```bash
103
+ # Check if still running
104
+ ssh_terminal poll=<session_id>
105
+
106
+ # Read output from completed command
107
+ ssh_terminal read_output=<session_id>
108
+
109
+ # Or via ssh_sessions
110
+ ssh_sessions action=poll session_id=<session_id>
111
+ ssh_sessions action=read_output session_id=<session_id>
112
+ ```
113
+
114
+ ### `ssh_machines` — Machine Registry
115
+
116
+ Register servers once, refer to them by name or alias.
117
+
118
+ ```bash
119
+ # Add a server
120
+ ssh_machines action=add name=web1 host=192.168.1.50 user=deploy key=~/.ssh/id_ed25519
121
+
122
+ # Add with aliases and tags
123
+ ssh_machines action=add name=prod-web host=10.0.0.1 aliases=web1,tags=production,web
124
+
125
+ # List all machines
126
+ ssh_machines action=list
127
+
128
+ # Test connectivity
129
+ ssh_machines action=test name=web1
130
+
131
+ # Get full details
132
+ ssh_machines action=inspect name=web1
133
+ ```
134
+
135
+ Machine names must be alphanumeric with dots, hyphens, or underscores (1-64 chars). Slashes, spaces, and glob characters are rejected.
136
+
137
+ ### `ssh_sessions` — Session Tracking
138
+
139
+ Every command creates a session. Sessions track the PID, machine, command count, and idle time.
140
+
141
+ ```bash
142
+ # List active sessions
143
+ ssh_sessions action=list
144
+
145
+ # Kill a session (terminates the SSH process)
146
+ ssh_sessions action=kill session_id=<session_id>
147
+
148
+ # Clean up all idle sessions (>30 min)
149
+ ssh_sessions action=cleanup
150
+
151
+ # Remove old closed sessions (>24 hours)
152
+ ssh_sessions action=prune
153
+ ```
154
+
155
+ Idle sessions are automatically killed by a background checker after 30 minutes. Closed sessions are pruned after 24 hours.
156
+
157
+ ### `/ssh` Slash Command
158
+
159
+ Quick access from chat without remembering tool names:
160
+
161
+ ```
162
+ /ssh # List machines and sessions
163
+ /ssh web1 # Inspect a machine
164
+ /ssh web1 uptime # Run a command
165
+ /ssh web1 docker ps # Run a command
166
+ /ssh test # Test connectivity to all machines
167
+ /ssh cleanup # Kill all idle sessions
168
+ /ssh help # Show help
169
+ ```
170
+
171
+ ## Configuration
172
+
173
+ All settings live in `src/ssh_tools/config.py` as an `SSHConfig` dataclass:
174
+
175
+ | Setting | Default | Description |
176
+ |---------|---------|-------------|
177
+ | `default_port` | 22 | SSH port for new machines |
178
+ | `default_user` | root | SSH user for new machines |
179
+ | `connect_timeout` | 5s | SSH handshake timeout |
180
+ | `command_timeout` | 30s | Command execution timeout |
181
+ | `max_output_chars` | 50,000 | Output truncation threshold |
182
+ | `idle_check_interval` | 60s | Seconds between idle checks |
183
+ | `idle_timeout_minutes` | 30m | Auto-kill after this idle time |
184
+ | `closed_prune_hours` | 24h | Remove closed sessions after this |
185
+ | `strict_host_key_checking` | accept-new | SSH host key verification |
186
+
187
+ ## Architecture
188
+
189
+ ```
190
+ src/ssh_tools/
191
+ ├── __init__.py # Plugin registration + Hermes hooks
192
+ ├── config.py # SSHConfig (immutable dataclass)
193
+ ├── manager.py # SSHManager — all state and operations
194
+ ├── models.py # Machine, Session dataclasses
195
+ ├── schemas.py # Tool schemas (LLM-facing)
196
+ ├── utils.py # ok(), err(), require() helpers
197
+ ├── py.typed # PEP 561 marker
198
+ └── handlers/
199
+ ├── terminal.py # ssh_terminal (execute, poll, read_output)
200
+ ├── machines.py # ssh_machines (add/list/remove/test/inspect)
201
+ ├── sessions.py # ssh_sessions (list/kill/cleanup/prune/poll/read_output)
202
+ └── slash.py # /ssh slash command
203
+ ```
204
+
205
+ **Key design decisions:**
206
+
207
+ - `SSHManager` owns all state. Thread-safe. No module-level mutable state.
208
+ - Handlers are thin closures — validate params, dispatch to manager, return JSON.
209
+ - JSON files use atomic writes (temp file + `os.replace`) for crash safety.
210
+ - Data directory has restricted permissions (0o700). Audit log and output files use 0o600.
211
+ - Machine names are validated to prevent path traversal and glob injection.
212
+ - Connection reuse via `ControlMaster` with 5-minute persist window.
213
+
214
+ ## Security
215
+
216
+ See [SECURITY.md](SECURITY.md) for the full picture.
217
+
218
+ **Defaults you should know about:**
219
+
220
+ - `StrictHostKeyChecking=accept-new` — accepts first-seen host keys but rejects changed keys. Set to `yes` for strict production hosts.
221
+ - Machine credentials are encrypted at rest in `~/.hermes/ssh-tools/machines.json`. Data directory is 0o700.
222
+ - All commands execute with the permissions of the Hermes agent process.
223
+
224
+ **Hardening applied:**
225
+
226
+ - Machine names validated against `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
227
+ - Output files written with 0o600 permissions
228
+ - Audit log created with 0o600 permissions
229
+ - Atomic JSON writes prevent corruption on crash
230
+ - Orphaned temp files cleaned on startup
231
+
232
+ ## Requirements
233
+
234
+ - Python 3.11+
235
+ - OpenSSH client (`ssh`)
236
+ - [Hermes Agent](https://github.com/NousResearch/hermes-agent)
237
+
238
+ ## Troubleshooting
239
+
240
+ **Connection refused**
241
+ The remote host may not be listening on the expected port, or a firewall is blocking the connection. Verify with `ssh -v user@host` outside of Hermes.
242
+
243
+ **Permission denied (publickey)**
244
+ The SSH key path stored in the machine registry may be incorrect, or the remote host doesn't have the corresponding public key in `~/.ssh/authorized_keys`. Verify with `ssh -i /path/to/key user@host`.
245
+
246
+ **Command timeout**
247
+ Commands exceeding `command_timeout` (default 30s) are killed. Increase the timeout or use `background=true` for long-running work.
248
+
249
+ **Output looks truncated**
250
+ This is intentional — large outputs are saved under the restricted plugin output directory and a summary is returned. Use `read_output` or `read_file` on the returned path for the full output.
251
+
252
+ **Session stuck as "active" after process died**
253
+ If the agent restarted, background process references are lost. Use `ssh_sessions action=cleanup` to kill stale sessions, or `ssh_sessions action=prune` to remove old closed ones.
254
+
255
+ ## Development
256
+
257
+ ```bash
258
+ git clone https://github.com/TheEpTic/hermes-plugins.git
259
+ cd hermes-plugins/hermes-ssh
260
+ python -m venv .venv && source .venv/bin/activate
261
+ pip install -e '.[dev]'
262
+
263
+ # Run checks
264
+ black --check src/ssh_tools/ tests/
265
+ mypy src/ssh_tools/
266
+ pytest
267
+ ```
268
+
269
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
270
+
271
+ ## License
272
+
273
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
1
+ ssh_tools/__init__.py,sha256=4DiMx-AhnNmAuHHbTmkrTtqiMkw6aSFqlCl9QEg4IDY,2639
2
+ ssh_tools/approval.py,sha256=30Npl1rCAueeKKRds3_fXB8zL5J-CD4GnzpSMgx5q3k,1372
3
+ ssh_tools/config.py,sha256=CBLNn2oaTe5D0NDaCpVbCATYoOJp4ZtdO4p1R_0hN7c,2103
4
+ ssh_tools/manager.py,sha256=VemehZRhUjbWqLDUzbkQvcO2XDGxRwmCeOJQFV9xWpY,33067
5
+ ssh_tools/migrate.py,sha256=8tgdnW30J-Ladps9eUR8IFr65GxJcg7a551YflBZU9Q,3548
6
+ ssh_tools/models.py,sha256=mGjMgZctdFO_omN8gW79_Nc_TADKbGnI9DUij31qd0I,2985
7
+ ssh_tools/plugin.yaml,sha256=soHtlL0RAZUWYjIyKNPBFAaxDUof0ryYdN-DaBc2LZk,303
8
+ ssh_tools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ ssh_tools/schemas.py,sha256=N-jyf-n3l4yJG8OekaNG583IouglIB75HqRs-tPEfvc,4690
10
+ ssh_tools/storage.py,sha256=bUcEyRf8gGtO6rqaKylzJqxs2I6yoUSE_8F3Df9439s,6002
11
+ ssh_tools/utils.py,sha256=p2qjZijTLEfu6boIc5f0vG3Z7IRrkOoiWOC_4nloseo,1018
12
+ ssh_tools/handlers/__init__.py,sha256=9zbDGQV7qlVa7AIAmkIL0HQFQ4YjjcBs00Vb-UC56Jw,244
13
+ ssh_tools/handlers/machines.py,sha256=gGl5Z1daWqChqXm8wrkK2HeNPLxAr-DrJFM0TIcOKHQ,3308
14
+ ssh_tools/handlers/sessions.py,sha256=HvLoI91uXaxdg2qNiNUuM0IXQkQpakIrqpNfynUcjr0,3025
15
+ ssh_tools/handlers/slash.py,sha256=ezj7b4qxZcGJf2vsl2yIty-oBpJMmc8DusqpxF9wGeA,4859
16
+ ssh_tools/handlers/terminal.py,sha256=QdQj2t627ea9QhmxCGpmd-zs3785eC282bAMpJeqBdk,2993
17
+ hermes_ssh-0.3.1.dist-info/METADATA,sha256=YNoTSrfQEzv2h9cbIoxbJalW8WW-ajTj0CGTSflmrLI,9684
18
+ hermes_ssh-0.3.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
+ hermes_ssh-0.3.1.dist-info/entry_points.txt,sha256=sifscbypKTTI9jbsFRDf5eSYdf5qvXAaOW9uE9N61j8,46
20
+ hermes_ssh-0.3.1.dist-info/licenses/LICENSE,sha256=eA6I1UzoZMbxfsHuSp1YD6bVF9QkWcPiEf-CQcMF-Jw,1065
21
+ hermes_ssh-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ hermes-ssh = ssh_tools
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheEpTic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ssh_tools/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """hermes-ssh — SSH remote execution plugin for Hermes Agent.
2
+
3
+ Provides:
4
+ - ssh_terminal: Run commands on remote machines via SSH
5
+ - ssh_machines: Machine registry (add/list/remove/test/inspect)
6
+ - ssh_sessions: Active session tracking (list/kill/cleanup)
7
+ - /ssh slash command for quick access
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from .handlers import handle_ssh_machines, handle_ssh_sessions, handle_ssh_terminal
16
+ from .handlers.slash import create_slash_handler
17
+ from .manager import SSHManager
18
+ from .schemas import SSH_MACHINES_SCHEMA, SSH_SESSIONS_SCHEMA, SSH_TERMINAL_SCHEMA
19
+
20
+ __version__ = "0.2.0"
21
+ __all__ = [
22
+ "SSH_MACHINES_SCHEMA",
23
+ "SSH_SESSIONS_SCHEMA",
24
+ "SSH_TERMINAL_SCHEMA",
25
+ "SSHManager",
26
+ "handle_ssh_machines",
27
+ "handle_ssh_sessions",
28
+ "handle_ssh_terminal",
29
+ "register",
30
+ ]
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Module-level manager — initialized in register()
35
+ _manager: SSHManager | None = None
36
+
37
+
38
+ def _get_manager() -> SSHManager:
39
+ global _manager
40
+ if _manager is None:
41
+ raise RuntimeError("hermes-ssh plugin not registered. Call register() first.")
42
+ return _manager
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Plugin registration
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def register(ctx: Any) -> None:
51
+ """Register SSH tools with Hermes."""
52
+ global _manager
53
+ if _manager is not None:
54
+ logger.debug("hermes-ssh: already registered, skipping")
55
+ return
56
+ _manager = SSHManager()
57
+
58
+ # Tools
59
+ ctx.register_tool(
60
+ name="ssh_terminal",
61
+ toolset="ssh_tools",
62
+ schema=SSH_TERMINAL_SCHEMA,
63
+ handler=handle_ssh_terminal(_manager),
64
+ description="Run a command on a remote machine via SSH.",
65
+ )
66
+ ctx.register_tool(
67
+ name="ssh_machines",
68
+ toolset="ssh_tools",
69
+ schema=SSH_MACHINES_SCHEMA,
70
+ handler=handle_ssh_machines(_manager),
71
+ description="Manage the SSH machine registry.",
72
+ )
73
+ ctx.register_tool(
74
+ name="ssh_sessions",
75
+ toolset="ssh_tools",
76
+ schema=SSH_SESSIONS_SCHEMA,
77
+ handler=handle_ssh_sessions(_manager),
78
+ description="Manage active SSH sessions.",
79
+ )
80
+
81
+ # Slash command
82
+ slash_handler = create_slash_handler(_get_manager)
83
+ ctx.register_command(
84
+ "ssh",
85
+ handler=slash_handler,
86
+ description="SSH session management — machines, sessions, idle alerts.",
87
+ )
88
+
89
+ # Start background idle checker
90
+ _manager.start_idle_checker()
91
+
92
+ logger.info("hermes-ssh plugin loaded")
ssh_tools/approval.py ADDED
@@ -0,0 +1,40 @@
1
+ """Hermes dangerous-command approval integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ try:
11
+ from tools.approval import (
12
+ _get_approval_mode,
13
+ check_dangerous_command as _check_dangerous,
14
+ ) # pyright: ignore[reportMissingImports]
15
+ except ImportError:
16
+ _check_dangerous = None
17
+ _get_approval_mode = None
18
+ logger.warning("Hermes approval system not available — SSH commands will fail closed")
19
+
20
+
21
+ def check_approval(command: str) -> dict[str, Any] | None:
22
+ """Return a denial result, or None when the command is approved/unchecked."""
23
+ if _check_dangerous is None:
24
+ return {
25
+ "approved": False,
26
+ "message": "SSH command blocked: Hermes approval system is unavailable",
27
+ }
28
+ if _get_approval_mode is not None and _get_approval_mode() == "off":
29
+ return None
30
+ result: dict[str, Any] = _check_dangerous(command, env_type="ssh")
31
+ if result.get("status") == "approval_required":
32
+ description = str(result.get("description") or "command flagged")
33
+ result = {
34
+ **result,
35
+ "message": (
36
+ f"approval required: {description}. the user must reply with /approve or /deny.\n\n"
37
+ f"command:\n```\n{command}\n```"
38
+ ),
39
+ }
40
+ return result
ssh_tools/config.py ADDED
@@ -0,0 +1,68 @@
1
+ """Centralized configuration for ssh-tools plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ PLUGIN_DIR = Path(__file__).parent
11
+ DEFAULT_DATA_DIR = Path.home() / ".hermes" / "ssh-tools"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SSHConfig:
16
+ """Immutable plugin configuration."""
17
+
18
+ data_dir: Path = field(default=DEFAULT_DATA_DIR)
19
+
20
+ # SSH defaults
21
+ default_port: int = 22
22
+ default_user: str = "root"
23
+ connect_timeout: int = 5
24
+ command_timeout: int = 30
25
+ strict_host_key_checking: str = "accept-new"
26
+
27
+ # Output
28
+ max_output_chars: int = 50_000 # save to output_dir if exceeded
29
+
30
+ # Session management
31
+ idle_check_interval: int = 60 # seconds between idle checks
32
+ idle_timeout_minutes: int = 30 # auto-kill after this
33
+ closed_prune_hours: int = 24 # remove closed sessions after this
34
+
35
+ # Paths (derived from data_dir)
36
+ @property
37
+ def machines_file(self) -> Path:
38
+ return self.data_dir / "machines.json"
39
+
40
+ @property
41
+ def sessions_file(self) -> Path:
42
+ return self.data_dir / "sessions.json"
43
+
44
+ @property
45
+ def socket_dir(self) -> Path:
46
+ return self.data_dir / "sockets"
47
+
48
+ @property
49
+ def output_dir(self) -> Path:
50
+ return self.data_dir / "outputs"
51
+
52
+ def ensure_dirs(self) -> None:
53
+ """Create all required directories with restricted permissions."""
54
+ self.data_dir.mkdir(parents=True, exist_ok=True)
55
+ self.socket_dir.mkdir(parents=True, exist_ok=True)
56
+ self.output_dir.mkdir(parents=True, exist_ok=True)
57
+ # Clean orphaned temp files from previous crashes
58
+ for tmp in self.data_dir.glob("*.tmp"):
59
+ with contextlib.suppress(OSError):
60
+ tmp.unlink()
61
+ # Restrict permissions — data dir contains machine credentials
62
+ for d in (self.data_dir, self.socket_dir, self.output_dir):
63
+ with contextlib.suppress(OSError):
64
+ os.chmod(d, 0o700)
65
+
66
+
67
+ # Module-level default — importable, overridable for tests
68
+ DEFAULT_CONFIG = SSHConfig()
@@ -0,0 +1,7 @@
1
+ """Tool handlers for hermes-ssh."""
2
+
3
+ from .machines import handle_ssh_machines
4
+ from .sessions import handle_ssh_sessions
5
+ from .terminal import handle_ssh_terminal
6
+
7
+ __all__ = ["handle_ssh_machines", "handle_ssh_sessions", "handle_ssh_terminal"]
@@ -0,0 +1,97 @@
1
+ """Handler for the ssh_machines tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from ..models import Machine
8
+ from ..utils import err, ok, require
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from ..manager import SSHManager
14
+
15
+
16
+ def handle_ssh_machines(manager: SSHManager) -> Callable[[dict[str, Any]], str]:
17
+ """Create a handler for ssh_machines that captures manager via closure."""
18
+
19
+ def _handle(params: dict[str, Any], **kwargs: Any) -> str:
20
+ action = params.get("action", "list")
21
+
22
+ if action == "list":
23
+ machines = manager.list_machines()
24
+ return ok(
25
+ machines={
26
+ name: {
27
+ "host": m.host,
28
+ "user": m.user,
29
+ "port": m.port,
30
+ "aliases": m.aliases or [],
31
+ "tags": m.tags or [],
32
+ "description": m.description,
33
+ }
34
+ for name, m in machines.items()
35
+ },
36
+ count=len(machines),
37
+ )
38
+
39
+ if action == "add":
40
+ error = require(params, "name", "host")
41
+ if error:
42
+ return err(error)
43
+ try:
44
+ machine = manager.add_machine(
45
+ Machine(
46
+ name=params["name"],
47
+ host=params["host"],
48
+ user=params.get("user", "root"),
49
+ port=params.get("port", 22),
50
+ key=params.get("key", ""),
51
+ aliases=params.get("aliases", []),
52
+ tags=params.get("tags", []),
53
+ description=params.get("description", ""),
54
+ )
55
+ )
56
+ except ValueError as exc:
57
+ return err(str(exc))
58
+ return ok(machine=machine.to_dict())
59
+
60
+ if action == "remove":
61
+ error = require(params, "name")
62
+ if error:
63
+ return err(error)
64
+ name = params["name"]
65
+ if not isinstance(name, str):
66
+ return err("name must be a string")
67
+ removed = manager.remove_machine(name)
68
+ return ok(
69
+ success=removed,
70
+ message=f"Removed '{name}'" if removed else f"'{name}' not found",
71
+ )
72
+
73
+ if action == "inspect":
74
+ error = require(params, "name")
75
+ if error:
76
+ return err(error)
77
+ name = params["name"]
78
+ if not isinstance(name, str):
79
+ return err("name must be a string")
80
+ inspected = manager.get_machine(name)
81
+ if not inspected:
82
+ return err(f"Machine '{name}' not found")
83
+ canonical = manager.resolve_name(name)
84
+ return ok(name=canonical, machine=inspected.to_dict())
85
+
86
+ if action == "test":
87
+ error = require(params, "name")
88
+ if error:
89
+ return err(error)
90
+ name = params["name"]
91
+ if not isinstance(name, str):
92
+ return err("name must be a string")
93
+ return ok(**manager.test_machine(name))
94
+
95
+ return err(f"Unknown action: {action}")
96
+
97
+ return _handle