SafeReach
Read-only production diagnostics over SSH, as an MCP server.
Gives an AI agent a competent read-only SRE's eyes on your fleet — journal, service state,
disk, memory, processes, sockets, container logs and inspect, HTTP health checks, across
many hosts in parallel — with the hands removed.
The agent can investigate. It cannot change anything, read a secret, escape into a shell, or reach a host it wasn't granted.
Quick start
uvx safereach@0.1.1 enroll --all # set up every server you can already ssh to
uvx safereach@0.1.1 install # register with your agents
Pre-release: until this is on PyPI, install from source and register with
safereach install --launcher script. Theuvxform above is whatinstallwrites once the package is published.
Two commands. No sudo required, no config file to edit, nothing installed globally.
If ssh myserver works today, the agent can diagnose myserver.
Architecture
The validator runs twice, on both sides of the SSH connection. That is the central design decision and the reason this can be pointed at production.
flowchart LR
subgraph LOCAL["your machine"]
AGENT["AI agent<br/>Claude Code · Codex · Cursor"]
MCP["safereach<br/><i>MCP server, stdio</i>"]
V1["validator<br/><i>client-side: fast,<br/>helpful denials</i>"]
AUD["audit log<br/><i>JSONL</i>"]
AGENT <-->|"JSON-RPC"| MCP
MCP --> V1
MCP --> AUD
end
subgraph REMOTE["remote host"]
SSHD["sshd<br/><i>forced command</i>"]
SHIM["safereach-shim<br/><i>root-owned</i>"]
V2["validator<br/><b>the real boundary</b>"]
RED["redaction<br/><i>3 layers</i>"]
PROXY["docker socket proxy<br/><i>POST=0 EXEC=0</i>"]
CMD["allowlisted binaries<br/><i>as unprivileged diag</i>"]
SSHD --> SHIM --> V2 --> CMD
CMD --> RED
SHIM -.->|"DOCKER_HOST"| PROXY
end
V1 -->|"SSH · key pinned to<br/>a forced command"| SSHD
RED -->|"masked output"| MCP
style V2 fill:#ffe0e0,stroke:#c00,stroke-width:2px
style RED fill:#fff3cd,stroke:#c90
style PROXY fill:#e0f0ff,stroke:#06c
Why twice. The MCP server runs on the agent's own machine, so a check that lives there is one the agent's environment can influence — via a compromised server, a poisoned context, or a prompt injection arriving in a log line the agent just read. Client-side validation is a user-experience feature: it fails fast and explains why. The shim is the control that actually holds, because it sits outside everything the agent can reach.
Request lifecycle
sequenceDiagram
participant A as Agent
participant M as MCP server
participant S as sshd
participant H as safereach-shim
participant C as Command
A->>M: run_command("journalctl -u nginx -n 200")
M->>M: resolve alias → host config
M->>M: validate (client-side)
Note over M: rejection → ToolError<br/>with a legal alternative
M->>S: @run ["journalctl","-u","nginx","-n","200"]
Note over M,S: structured argv, never a shell string
S->>H: forced command · $SSH_ORIGINAL_COMMAND
H->>H: validate_argv (independently)
Note over H: no tokenisation here —<br/>the two sides cannot disagree
H->>C: exec, no shell, no PTY
C-->>H: stdout
H->>H: redact: structural → by-name → by-digest
H-->>M: masked output
M->>M: audit record
M-->>A: CommandResult
Defence in depth
flowchart TD
T["agent asks for<br/>something destructive"] --> L1
L1{"client validator"} -->|refused| X1["explained, with<br/>a legal alternative"]
L1 -->|"bug / bypassed"| L2
L2{"SSH forced command"} -->|refused| X2["the key can only<br/>invoke the shim"]
L2 --> L3
L3{"shim validator"} -->|refused| X3["independent of<br/>the agent's machine"]
L3 --> L4
L4{"docker socket proxy"} -->|refused| X4["mutation blocked at<br/>the API level"]
L4 --> L5
L5{"unprivileged diag<br/>no sudo, no docker group"} -->|refused| X5["no privilege<br/>to abuse"]
style L3 fill:#ffe0e0,stroke:#c00,stroke-width:2px
No single failure is catastrophic. A parser bug lands on the shim. A shim bug lands on the socket proxy. A proxy bug lands on an account that cannot do much anyway.
Installation
Recommended — uvx, pinned
uvx safereach@0.1.1 --help
Nothing installed globally, and it is the one launch form that works identically for every
agent. A registration pointing at /home/you/project/.venv/bin/... breaks the moment
anything moves; uvx does not.
The version pin is deliberate. Bare uvx safereach refetches from PyPI on every
launch — fine for most tools, wrong for one holding production SSH keys, since it is a
standing supply-chain exposure on the component whose entire job is being a security
boundary. safereach install pins automatically; --unpinned opts out, and is not
recommended.
Alternative — a persistent install
uv tool install safereach==0.1.1
From source
git clone https://github.com/Ghost-141/SafeReach && cd safereach
uv venv && uv pip install -e ".[dev]"
uv run pytest
Requirements: Python 3.11+ locally. On managed hosts, any Python 3 — the shim is a single stdlib-only file, deliberately, so production hosts need nothing installed.
Setting up hosts
enroll — the default
safereach enroll myserver # one host
safereach enroll --all # everything in ~/.ssh/config
Uses the SSH access you already have. No sudo. It generates a dedicated keypair, copies
the shim to ~/.local/bin/, and appends one entry to the remote authorized_keys:
command="/home/you/.local/bin/safereach-shim",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc ssh-ed25519 AAAA…
Your other entries are untouched, so your own SSH is unaffected — but the agent's key can
invoke nothing except the shim, whatever is sent to it. Same mechanism as
borg serve --restrict-to-path, rrsync and gitolite.
The insight: command= is a per-key option in a user-owned file. It needs no root. The
forced command — the actual security boundary — costs nothing to install, so there is no
reason to run without it.
Enrolment then verifies the restriction by attempting an escape, and refuses to record the host if that escape succeeds.
enroll --hardened — for production
safereach enroll myserver --hardened --elevated dmesg-recent
Needs sudo on the target once. Additionally:
- creates an unprivileged
diaguser — no sudo, not in thedockergroup - installs the shim to
/usr/local/binand its policy to/etc/safereach, both root-owned, so the account cannot rewrite what it is allowed to run - starts a read-only Docker socket proxy (
POST=0 EXEC=0), bound to localhost - writes an exact-match sudoers entry for enabled recipes only — never
sudoitself - makes the audit log append-only (
chattr +a), so the account cannot erase its trail
Naming your hosts
The alias is what the agent types, what list_hosts shows, and what every audit record is
keyed on — so enrolment asks:
Choose a name for each host (Enter accepts the suggestion):
deploy@10.0.1.5 [web-01] > prod-web
bdren@203.96.189.202 [203.96.189.202] > langfuse-prod
Suggestions come from the ~/.ssh/config Host entry, then the first DNS label
(db.eu.internal → db), then the raw address. Prompting is TTY-gated, so scripted and
CI enrolment take the suggestion and never block.
safereach enroll web-01 --name prod-web # name a single host
safereach enroll --all --names names.yaml # from a file
safereach enroll --all --no-prompt # take the suggestions
Rename at any time — local only, no re-enrolment, because the remote host never knew the name:
safereach rename 203.96.189.202 langfuse-prod
safereach rename --interactive
safereach rename --write-names names.yaml # dump for editing
safereach rename --from names.yaml # apply
A names file may be written either way round; the direction is resolved against the hosts actually known, falling back to which side parses as an address. When neither settles it the entry is refused rather than guessed — a mapping read backwards points the agent at the wrong machine.
Every host also carries a stable id, derived from hostname:port and recorded in each
audit entry alongside the name. Renaming therefore does not sever a host's history —
which matters, because a rename usually happens exactly when something has gone wrong and
you want that history.
Mode comparison
discover |
enroll |
enroll --hardened |
|
|---|---|---|---|
| Remote validator behind a forced command | ✗ | ✓ | ✓ |
| Agent's key can get a shell | yes | no | no |
| Unprivileged dedicated account | ✗ | ✗ | ✓ |
| Docker via read-only proxy | ✗ | ✗ | ✓ |
| Shim rewritable by the account | — | yes | no |
| Append-only audit log | ✗ | ✗ | ✓ |
| Needs sudo | no | no | once |
list_hosts reports each host's mode, so the agent — and you — can see it.
Secret protection — four layers
flowchart TD
O["command output"] --> L0
L0["<b>Layer 0 · structural</b><br/>the data is never produced"] --> L1
L1["<b>Layer 1 · protected paths</b><br/>.env can't even be named"] --> L2
L2["<b>Layer 2 · by name</b><br/>learned from the host's .env keys"] --> L3
L3["<b>Layer 3 · by digest</b><br/>catches values with no name attached"] --> OUT["masked output"]
style L0 fill:#d4edda,stroke:#28a745
style L1 fill:#d4edda,stroke:#28a745
Layer 0 — remove the capability. A control that deletes a field always beats one that
filters it. systemctl show requires --property from a safe enum, so Environment= is
unrequestable. docker compose config is denied (it renders every resolved secret and has
no flag to suppress them); --services is a separate permitted path. kubectl get loses
-o yaml|json, where inline env: lives.
Layer 1 — protected paths. *.env, *.pem, *.key, id_rsa*, */.ssh/*,
*/.aws/*, /etc/shadow and ~30 more, checked against every argument token — a path
can arrive as a flag value. The list is compiled into the shim: a host policy may add
patterns, never remove them.
Layer 2 — masking by name. Enrolment reads the variable names from the host's .env
files and masks their values in four shapes (KEY=v, KEY: v, "KEY": "v", KEY = v).
Names only — cut -d= -f1 truncates before any value can escape.
Layer 3 — masking by digest. Catches a value appearing with no variable name — a
token in a stack trace, a password in a log line. Enrolment computes HMAC-SHA256 of each
value as root, on the host, and stores only digests. Gated on length ≥ 12 and entropy
≥ 3.0 bits/char, so production and localhost stay readable.
All masking happens on the host, before anything crosses the wire — so it holds even against someone using the enrolled key directly.
Non-destructive by construction
The agent cannot delete, remove, stop, restart, prune, kill or scale anything.
That guarantee used to depend on remembering to deny each verb per binary — until
ip route del default was found to be accepted, because ip's mutating verb sits in
the positional slot where subcommand denylists never look.
So it is now enforced by the build:
MUTATING_VERBS(75 verbs) lives invalidator.py— one source of truth, checked at runtime on both sides- a spec linter drives the real validator with every verb against every legal command prefix in the spec, and fails the build if any is reachable
- every binary must declare whether its positionals are commands or data, with a
written justification. Silence is not an option — silence is how
ipslipped through
Tools the agent sees
| Tool | Purpose |
|---|---|
list_hosts |
Aliases, descriptions, security mode. Never hostnames, users or key paths. |
select_host |
Pin a server for the session. |
describe_commands |
The allowlist in readable form — what may run, and how. |
run_command |
Validate → execute → structured result. host is optional. |
run_on_hosts |
Same, fanned out concurrently. The "who else is broken" tool. |
run_in_container |
Read-only commands inside a container, for logs not on stdout. |
run_elevated |
One named recipe (e.g. dmesg-recent). A name, never a command line. |
check_connectivity |
Reachability, auth, and the shim version handshake. |
Choosing a server
host is optional. One server configured → used directly. Several → the user is asked via
the client's elicitation UI and the answer is remembered for the session. No elicitation
support → an error naming the options, so the agent asks in conversation. It never
guesses.
Container inspection
docker logs covers apps logging to stdout. When a framework writes to a file instead
(/app/storage/logs/laravel.log), run_in_container runs a read-only command inside:
run_in_container("app-1", "tail -n 200 /app/storage/logs/laravel.log")
Enable with enroll --hardened --allow-exec --exec-container app-1. Off by default.
What keeps it safe: the inner command is validated by the same validator, against a
narrow in-container allowlist (cat, tail, head, ls, stat, ps, df, grep).
docker exec app sh -c '…' fails because sh is not allowlisted — not through a special
case. deny_paths still applies, so cat /app/.env is refused inside the container too.
No TTY, no stdin, no interactive session.
The tradeoff, stated plainly: --allow-exec requires POST on the Docker proxy, which
also permits container create/start at the API level. The command allowlist remains the
control; the proxy no longer is. Leave it off unless you need it.
Testing
uv run pytest # 832 tests
uv run ruff check .
uv run python shim/build.py
| Suite | What it covers |
|---|---|
test_validator_attacks |
Adversarial corpus — injection, traversal, escape-hatch binaries |
test_spec_lint |
Fails the build if any mutating verb is reachable |
test_canary |
Plants a known secret in 12 carriers; asserts it never escapes |
test_shim |
Differential — bundled shim must agree with the in-process validator |
test_secrets |
Protected paths and name-based masking |
test_kubernetes |
Read-only kubectl; secrets denied in every spelling |
test_stdio_clean |
stdout carries JSON-RPC and nothing else |
test_enroll |
The remote script never clobbers existing authorized_keys |
test_naming |
Alias validation, stable ids, name-file direction resolution |
test_rename |
Rewriting hosts.yaml in place without corrupting it |
The canary suite is the strongest evidence available: per-pattern tests prove the patterns work, but only a canary suggests nothing escapes. Each case has a negative control asserting the canary is present without redaction — otherwise a test that finds nothing proves only that the input was empty.
Release notes
0.1.1
Terminal output
--help,doctor,discoverandinstall --listrender as tables with a shared colour vocabulary, so a status means the same thing in every command- the console is bound to stderr, never stdout — stdout carries JSON-RPC in server
mode, and one styled byte there corrupts the stream for every agent. Five tests hold
that line, including one asserting the bundled shim still imports no
rich - colour is dropped when piped and when
NO_COLORis set
Help
- commands are grouped by task with a quick start on top, rather than listed alphabetically — the generated list said which commands existed, not which to run first
- every subcommand gained a description;
help=only ever fed the parent's listing, sosafereach enroll --helphad been flags and nothing else safereachwith no arguments on a terminal now shows help instead of starting a silent server and appearing to hang. Agents launch it over a pipe, so the two cases are distinguishable- added
--version
Release automation
- publishing is triggered by a version change rather than by merging. PyPI versions are immutable, so publishing on every merge would fail on the first merge that did not bump
- PEP 740 attestations bind each artifact to the commit and workflow that built it
- the GitHub release is tagged only after a successful upload, so a tag always names something installable
0.1.0 — initial release
Core
- MCP server (SDK v2, stdio) exposing eight read-only diagnostic tools
- Two-sided validation: client-side for error quality, remote shim as the real boundary
- Structured argv over the wire — no tokenisation on the remote side, so the two validators cannot disagree about quoting
- Connection pooling, per-command timeouts, output caps, concurrent fan-out
Security
- SSH forced command; enrolment verifies the restriction and refuses the host if an escape succeeds
- Hardened mode: unprivileged account, root-owned shim and policy, read-only Docker proxy, exact-match sudoers, append-only audit log
- Four-layer secret protection (structural · paths · names · digests), applied on the host
- Spec linter enforcing non-destructiveness at build time
- Shim fingerprinting: a drifted host is refused, not warned about
Allowlist — journalctl, systemctl, dmesg, df, du, free, uptime, nproc,
hostnamectl, ps, ss, ip, tail, head, grep, stat, ls, docker, curl,
kubectl
Known limits
- Masking is best-effort for unstructured text; Layers 0–1 are structural, 2–3 are not
- The
diagaccount is inadm, so it can read/var/log— inherent to being useful - Kubernetes RBAC is not automated; the allowlist is not a substitute for a read-only Role
- ~900 lines of security-critical code with no external audit yet
Extending the allowlist
config/commands.yaml is data. Adding a binary means enumerating its safe flags:
mytool:
description: What it does
flags:
"-n": { alias: "--lines", value: { type: int, min: 1, max: 2000 } }
positionals:
max: 2
pattern: '/var/log/[A-Za-z0-9._/\-]{1,200}'
path_prefixes: ["/var/log/"]
deny_flags:
"-o": "writes a file to disk"
Check it against these before adding:
- Can it spawn a child process? (
-exec,--to-command,!escapes) → don't add it - Can it write a file? → deny those flags explicitly
- Can it read an arbitrary path? → constrain
path_prefixes - Can it stream forever? (
-f,--follow) → deny; the timeout is a backstop, not a control - Can it read options from a file? (
curl -K) → deny; it bypasses the allowlist
Then run pytest. The spec linter will refuse anything mutating, and will require you to
declare whether the binary's positionals are commands or data.
For a privileged read, add an elevated recipe rather than widening the parser. Never
put sudo in commands.yaml — if the agent can pass arguments to sudo, the allowlist is
decorative.
Troubleshooting
safereach doctor # config, keys, connectivity, shim versions
safereach doctor --fix # re-push a drifted shim
safereach validate "journalctl -u nginx -n 200" --host myserver
| Symptom | Cause |
|---|---|
no safereach-shim installed |
Run safereach enroll <host> |
shim <x> != expected <y> |
Spec changed; doctor --fix |
Permission denied (publickey) |
Remote ~/.ssh must be 700, authorized_keys 600 |
Too many authentication failures |
Add IdentitiesOnly yes to ~/.ssh/config |
| Agent reports a parse error | Something wrote to stdout; check stderr |
Releasing
Publishing is automatic, and triggered by the version number, not by merging.
git switch -c release-0.1.1
sed -i 's/^version = .*/version = "0.1.1"/' pyproject.toml
gh pr create --fill # merge through review as usual
When that PR merges, the publish workflow sees pyproject.toml changed, compares the
version against the previous commit, confirms it is not already on PyPI, then runs the
spec linter, the full suite, lint, format check, the build, a README render check, and a
clean-environment install of the built wheel — and only then uploads. The GitHub release
is tagged afterwards, so a tag always names something that is actually installable.
Merging anything that does not change the version is silently a no-op.
Why gate on the version rather than publish on every merge: PyPI versions are immutable. The first merge that did not bump the version would fail with "file already exists", and CI would stay red from then on.
Uploads use Trusted Publishing (OIDC) with PEP 740 attestations — no API token is stored anywhere, and each artifact is cryptographically bound to the commit and workflow that produced it. That matters for a package people install and then point at their own production servers; the attestation is shown on the PyPI project page.
Actions → Publish → Run workflow still allows a manual run, including to TestPyPI.
Licence
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file safereach-0.1.1.tar.gz.
File metadata
- Download URL: safereach-0.1.1.tar.gz
- Upload date:
- Size: 178.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6420425b2d7e9a74f7111df957db7a60b146e2016ea8ea6d1a2a66c14e3bcf54
|
|
| MD5 |
5595aab7b61cb23c96abc15abe14a192
|
|
| BLAKE2b-256 |
beac141386f875e4d219b37441c22429b34f262208abeb94fc42b2615b8b0b03
|
Provenance
The following attestation bundles were made for safereach-0.1.1.tar.gz:
Publisher:
publish.yml on Ghost-141/SafeReach
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safereach-0.1.1.tar.gz -
Subject digest:
6420425b2d7e9a74f7111df957db7a60b146e2016ea8ea6d1a2a66c14e3bcf54 - Sigstore transparency entry: 2704150580
- Sigstore integration time:
-
Permalink:
Ghost-141/SafeReach@7d007d7273674dc32f1373e2fb8a106f677ba48e -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Ghost-141
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7d007d7273674dc32f1373e2fb8a106f677ba48e -
Trigger Event:
push
-
Statement type:
File details
Details for the file safereach-0.1.1-py3-none-any.whl.
File metadata
- Download URL: safereach-0.1.1-py3-none-any.whl
- Upload date:
- Size: 97.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e52af7029d3a44ae72e88bbffb148db5e730f144d6b7bb2ee1cefb1e613686db
|
|
| MD5 |
e48ae000a917dc1c93a2b7bf995571c3
|
|
| BLAKE2b-256 |
46b10e8c63b7bb4ad4b94ff9b697139189d89f0af103bdf28a193f962fdec2ce
|
Provenance
The following attestation bundles were made for safereach-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on Ghost-141/SafeReach
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safereach-0.1.1-py3-none-any.whl -
Subject digest:
e52af7029d3a44ae72e88bbffb148db5e730f144d6b7bb2ee1cefb1e613686db - Sigstore transparency entry: 2704150936
- Sigstore integration time:
-
Permalink:
Ghost-141/SafeReach@7d007d7273674dc32f1373e2fb8a106f677ba48e -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Ghost-141
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7d007d7273674dc32f1373e2fb8a106f677ba48e -
Trigger Event:
push
-
Statement type: