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.
- hermes_ssh-0.3.1.dist-info/METADATA +273 -0
- hermes_ssh-0.3.1.dist-info/RECORD +21 -0
- hermes_ssh-0.3.1.dist-info/WHEEL +4 -0
- hermes_ssh-0.3.1.dist-info/entry_points.txt +2 -0
- hermes_ssh-0.3.1.dist-info/licenses/LICENSE +21 -0
- ssh_tools/__init__.py +92 -0
- ssh_tools/approval.py +40 -0
- ssh_tools/config.py +68 -0
- ssh_tools/handlers/__init__.py +7 -0
- ssh_tools/handlers/machines.py +97 -0
- ssh_tools/handlers/sessions.py +81 -0
- ssh_tools/handlers/slash.py +129 -0
- ssh_tools/handlers/terminal.py +75 -0
- ssh_tools/manager.py +838 -0
- ssh_tools/migrate.py +114 -0
- ssh_tools/models.py +107 -0
- ssh_tools/plugin.yaml +12 -0
- ssh_tools/py.typed +0 -0
- ssh_tools/schemas.py +131 -0
- ssh_tools/storage.py +179 -0
- ssh_tools/utils.py +36 -0
|
@@ -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
|
+
[](https://github.com/TheEpTic/hermes-plugins/actions/workflows/ci.yml)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](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,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,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
|