SafeHell
SafeHell is a local approval broker for SSH commands requested by AI coding agents. Credentials stay in an encrypted per-user vault, and every remote command is displayed in a separate foreground terminal before it can run.
Pre-1.0: review the security model and limitations before using SafeHell on production systems.
What it guarantees
- SafeHell never places stored passwords in
.safehell.toml, CLI arguments, MCP schemas, or audit logs; literal password values are redacted from buffered agent-facing output. - The vault is encrypted with an age X25519 identity stored in the operating-system credential store.
- Password credentials are bound to an exact host, port, and username.
- Every command needs a one-time
yapproval insafehell serve, except commands the project explicitly lists underautoapprove.allow. - Commands matching
autoapprove.denyare refused before any approval path, in both attended and unattended mode. - SSH host keys are checked against SafeHell's own
known_hosts; changed keys are rejected. - Command output is buffered, bounded, and redacted before it is returned to the caller. Redaction covers bearer tokens, private-key blocks,
scheme://user:pass@URLs, AWS access key ids, and*_URL/*_TOKEN/*_SECRET-style assignments, so container environment dumps lose their values as well as their secrets.
SafeHell does not sandbox the remote shell. Output redaction and agent hooks are defense-in-depth, not guarantees against every possible secret representation or bypass by another local process running as your user.
Install
macOS and Linux
|
The script downloads the latest release binary for your platform, verifies it against the release SHA256SUMS, and installs it to $HOME/.local/bin. Set SAFEHELL_VERSION to pin a tag or SAFEHELL_INSTALL_DIR to change the destination. Read the script before piping it to a shell.
Windows
irm https://raw.githubusercontent.com/suiflex/SafeHell/develop/scripts/install.ps1 | iex
safehell setup
Installs to %LOCALAPPDATA%\Programs\SafeHell\bin and honours the same SAFEHELL_VERSION and SAFEHELL_INSTALL_DIR overrides. It verifies the download against SHA256SUMS just as the POSIX installer does.
Homebrew
Scoop
scoop bucket add suiflex https://github.com/suiflex/scoop-bucket
scoop install safehell
npm
Installing downloads and verifies the release binary for your platform. npx @suiflex/safehell works too.
Cargo
Prebuilt binaries cover Linux, macOS, and Windows on both x86_64 and aarch64.
Update
Update the installed binary without changing the vault, project configuration, or agent integrations:
To install a specific release tag:
The update downloads the official installer and verifies the binary against the release SHA256SUMS before replacing the installed executable.
From source
Rust 1.85 or newer is required.
Configure a project
Run these commands from the project root:
Password entry requires a TTY. safehell init creates .safehell.toml and adds it to .git/info/exclude when the project is a Git repository.
Example config (never add secret fields):
= 1
= "1ef6c562-8c64-499e-a798-f74248d8ca04"
[]
= 60
= 1048576
= 120
= 60
= 10
= 300
= 1048576
[]
= "staging.example.com"
= 22
= "deploy"
[]
= "ssh-agent"
[]
= ["docker logs *", "docker ps*", "docker inspect -f *", "systemctl status *", "df -h"]
= ["rm *", "dd *", "mkfs*", "docker rm *", "docker run *", "chown *", "* > *", "curl *| *sh"]
Limits are capped at 10 minutes and 10 MiB. Unknown config fields are rejected, so fields such as password, secret, and private_key cannot be smuggled into a server entry.
Approval rules
Patterns use * as the only wildcard and are matched against the whole command string.
| Command matches | Result |
|---|---|
autoapprove.deny |
refused immediately, no prompt, never worth retrying |
autoapprove.allow |
runs without a prompt |
| neither | waits for y in the broker terminal, up to approval_timeout_seconds |
allow = ["*"] is rejected: automatic approval always has to name the commands it covers. An approval keeps covering the byte-identical command for approval_ttl_seconds, an identical command inside dedup_seconds is refused instead of run twice, and the broker stops executing once max_commands_per_hour is reached.
Run
Keep the broker visible in its own terminal:
safehell serve --yes runs the broker unattended: allow-listed commands still
execute, and anything that would need a prompt is denied instead of waiting.
Every decision is recorded in the audit log.
Then request a command from the project:
exec exits 3 when the broker refuses a command, separately from a transport
failure, so a wrapper can tell "denied" from "the broker is down".
Long commands and file transfer
execute blocks until the command finishes. For anything slow, start returns a
job_id as soon as the request is approved, and poll reads what has been
produced so far:
start -> job_id
poll -> status: running, stdout_offset: 6, "line1"
poll -> status: finished, exit_status: 0, "line2\nline3"
Pass the previous stdout_offset and stderr_offset back to poll to read only
what is new. A partial trailing line, and anything after an unterminated
-----BEGIN key header, is withheld until the job finishes, so redaction is never
applied to half a secret. The broker keeps the last 16 jobs and evicts finished
ones first.
A background job is still bounded by timeout_seconds, so raise that limit (up to
10 minutes) for work that needs it rather than expecting start to run forever.
docker logs -f and other endless commands are cut at the timeout.
get_file and put_file move a file through the same approval gate. What the
operator sees is the real remote command (head -c N -- '<path>' | base64 or
base64 -d > '<path>'), so autoapprove patterns apply to transfers too. The
local side of a transfer must stay inside the project directory, and
max_transfer_bytes caps both directions. Transfers return only the path, size,
and SHA-256: content lands on disk instead of in the agent's context, and is not
redacted, so treat an approved transfer as handing over the file.
Audit
Every decision, including refusals, is appended to a 0600 JSON Lines file:
Each entry carries the timestamp, project id, alias, SHA-256 of the command, the
outcome (approved, auto-approved, ttl-approved, denied, blocked,
duplicate, throttled, expired, unattended, executed, failed,
transferred, transfer-failed), the duration, and the exit status. Command
text is hashed rather than stored.
Pass the remote command as one quoted shell string so its quoting and operators are preserved exactly.
The broker shows the project, endpoint, command, and reason. It decrypts a password only after approval. The first connection to an unknown host also shows its key fingerprint and asks whether to trust it.
Agent integration
# Run from the project root; this is project-local by default.
# Comma-separated or repeated.
# Omit --agent to pick from a menu on a terminal.
# Optional: install into the user's global agent configuration.
install chooses its targets in exactly one of three ways:
- An explicit
--agentalways wins and is never second-guessed, so scripts keep working unchanged. - No
--agenton a terminal opens the picker, which lists what each target writes and pre-selects the agents already configured here.--globalskips the picker because the picker asks for the scope itself. - No
--agentand no terminal — a script, a CI job, or an agent shelling out — installs only for the agents whose own configuration is already present. When nothing is detected it installs nothing and names the choices.
Detection reads the agent's own configuration (.codex/, .claude/,
.cursor/, opencode.json, .hermes/, openclaw.json, .agents/rules/,
.windsurf/, .github/copilot-instructions.md, .clinerules, .roo/) and
never a file SafeHell wrote, so a re-run cannot add a target it created last
time.
Every install registers the stdio MCP server as shll — the short name is
what an agent types on every tool call — and seeds the policy line that tells
the model to prefer SafeHell over direct SSH. Per agent:
| Agent | MCP registration | Policy seed | Guard hook |
|---|---|---|---|
codex |
codex mcp add |
AGENTS.md (project) or .codex/AGENTS.md (global) |
.codex/hooks.json PreToolUse |
claude |
claude mcp add |
CLAUDE.md (project) or .claude/CLAUDE.md (global) |
.claude/settings.json PreToolUse |
cursor |
.cursor/mcp.json |
.cursor/rules/safehell.mdc |
— |
opencode |
opencode.json mcp map |
AGENTS.md (project) or .config/opencode/AGENTS.md |
— |
hermes |
hermes mcp add |
AGENTS.md (project only) |
— |
openclaw |
openclaw.json mcp.servers map |
AGENTS.md (project only) |
— |
antigravity |
.agents/mcp_config.json (project) or ~/.gemini/config/mcp_config.json |
.agents/rules/safehell.md (project) or .gemini/GEMINI.md |
— |
windsurf |
~/.codeium/windsurf/mcp_config.json |
AGENTS.md (project) or .codeium/windsurf/memories/global_rules.md |
— |
copilot |
.vscode/mcp.json servers map |
.github/copilot-instructions.md |
— |
cline |
VS Code cline_mcp_settings.json |
AGENTS.md (project) or .agents/AGENTS.md |
— |
roo |
VS Code cline_mcp_settings.json |
AGENTS.md (project) or .roo/rules/safehell.md |
— |
Four of those keep their MCP registry outside the repository, so SafeHell
writes it where the agent actually reads it rather than leaving a policy that
names tools nothing can reach: Windsurf and Hermes are user-level in both
scopes, and Cline and Roo Code store theirs in the VS Code profile — skipped
with a notice when that extension has never run here, because inventing the
profile tree would leave settings no editor loads. Copilot is the mirror case:
its instructions and .vscode/mcp.json are both repository-scoped, so
--global writes nothing for it instead of guessing at a user-level path.
Hermes keeps servers in ~/.hermes/config.yaml, which its own CLI owns —
SafeHell shells out to hermes mcp add rather than rewriting YAML.
Installing removes any earlier safeshell or safehell registration first
and replaces an existing shll registration, so upgrading never leaves two
servers exposing the same tools or a stale binary path behind. Policy seeds in
AGENTS.md, CLAUDE.md, and .gemini/GEMINI.md are created only when absent
and are never rewritten — the file may hold the user's own instructions.
Owned files (.cursor/rules/safehell.mdc, .agents/rules/safehell.md) are
refreshed on re-install. Existing JSON settings are backed up as
*.json.safehell.bak before modification. The guard blocks direct ssh,
scp, sftp, sshpass, and rsync calls. The MCP server exposes only:
list_serversexecute(write/destructive capable; still requires broker approval)startandpollfor long commandsget_fileandput_file, capped bymax_transfer_bytes
The hook does not rewrite commands and cannot prevent all bypasses. Agent policies should still restrict arbitrary shell execution where stronger isolation is required.
Data and audit
SafeHell uses the platform user-data directory for:
vault.age: encrypted credentialsknown_hosts: trusted SSH host keysaudit.jsonl: timestamp, project ID, alias, command SHA-256, approval, duration, outcome, and exit status
The audit log never stores raw commands, stdout, stderr, or credentials. Losing the OS credential-store identity makes vault.age unrecoverable; back up both together if recovery is required.
Development
SafeHell is dual-licensed under Apache-2.0 or MIT.