SSHX Documentation
SSH is the channel. X is execution.
sshx is an agent-native remote host execution tool. It uses SSH/SFTP to reach existing hosts and brings target resolution, execution preview, safety checks, command and file actions, structured results, and audit evidence into one CLI invocation.
It keeps the operating model simple: one command opens one connection, performs one explicit action, returns a decidable result, writes a local audit event, and exits. Nothing needs to be installed on the remote host and no long-running control plane is introduced.
The documentation starts in English by default. Use the language switch in the top navigation bar to open the matching Chinese page.
What SSHX Is Good At
- Run a remote command with predictable stdout, stderr, and exit-code behavior.
- Save sudo passwords in the operating system keyring instead of plaintext files.
- Use short host names from
~/.sshx/settings.jsoninstead of repeating IP, port, user, and key paths. - Perform small SFTP tasks without opening an interactive client.
- Replace one remote regular file with a hash, backup, and atomic write.
- Produce JSON output that scripts and AI agents can branch on.
- Preview local execution plans with
--dry-runbefore connecting, reading secrets, mutatingknown_hosts, or writing host config. - Keep a local JSONL audit trail without recording plaintext passwords, private keys, stdout, or stderr.
- Inspect system/network state in one call and create reusable application plugins under the sshx runtime root.
- Open a human-only interactive login on a named host (
sshx login, optional--sudo) without maintaining a second~/.ssh/config.
Mental Model
Think of sshx as a remote execution primitive in an agent’s toolbox, not a desired-state or workflow orchestration platform. sshx login is a narrow human escape hatch, not an OpenSSH replacement.
agent, automation, or human operator
|
v
agent contract: CLI / JSON / exit code / dry-run
|
v
X execution: target / safety / action / audit
|
v
SSH channel: auth / host key / SSH exec / SFTP
|
v
remote host
Common First Commands
# See available flags and examples
sshx --help
# Run a simple command
sshx -h=192.168.1.100 -u=root "uptime"
# Run against a named host
sshx -h=prod-web "systemctl is-active nginx"
# Preview what would happen before connecting
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
# Get machine-readable output for automation
sshx -h=prod-web --json "systemctl is-active nginx"
# Inspect a complete system/network baseline in one call
sshx inspect -h=prod-web system.baseline --json
Safety First
Remote access tools can cause real damage. The safe default path in sshx is strict:
- Host keys are checked through
known_hosts. - Passwords belong in the OS keyring, not in shell history or config files.
- Sudo passwords are sent through stdin, never interpolated into the command string.
- Obvious destructive commands are blocked unless the user explicitly bypasses checks.
- Safety checks are guardrails against mistakes; they are not a sandbox for untrusted commands.
Read Security Guidelines before using sshx in production or agent-driven workflows.
Where To Go Next
- Project Profile and Direction defines the product position, hard non-goals, and acceptance matrix (currently maintained in Chinese).
- Getting Started gets one host working.
- Host Management explains named hosts and key selection.
- Usage Scenarios gives practical examples for daily operations.
- Agent and Script Mode explains JSON output, exit codes, timeouts, and audit logs.
- Plans, Outcomes, and Safe Retries covers reviewed plan binding, risk/effect evidence, cancellation, and backend/platform limits.
- Inspection Capabilities and Local Plugins explains built-ins,
plugin create, trust, and observations. - SFTP Workflows covers upload, download, list, mkdir, and remove.
- Guarded File Apply replaces one remote file with backup and hash checks.
Getting Started
This guide walks through a first safe setup. The examples use prod-web as the host name; replace it with your own server name or IP address.
Install
If Go is already installed:
go install github.com/talkincode/sshx/cmd/sshx@latest
sshx --version
sshx skill install
sshx skill install writes the canonical skill embedded in the binary to
~/.agents/skills/sshx/SKILL.md. The same command should be run after a
Homebrew install or upgrade; prior sshx-managed versions update automatically.
Use --force only after reviewing a locally modified existing copy.
You can also run a specific version without installing:
go run github.com/talkincode/sshx/cmd/sshx@latest --help
Verify SSH Trust First
sshx checks host keys by default. Add the server to known_hosts before the first connection:
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
If you deliberately want first-use trust, use:
sshx --accept-unknown-host -h=prod-web "uptime"
Avoid --insecure-hostkey except in short-lived controlled labs. It disables the trust check that protects against man-in-the-middle attacks.
Run The First Command
sshx -h=prod-web -u=deploy "uptime"
Useful variations:
# Non-standard SSH port
sshx -h=prod-web -p=2222 -u=deploy "uptime"
# Specific SSH key
sshx -h=prod-web -u=deploy -i=~/.ssh/prod-web.pem "uptime"
# Bound a slow command
sshx -h=prod-web --timeout=30s "apt-get update"
Add A Named Host
Named hosts keep connection details in one local file:
sshx --host-add --host-name=prod-web -h=192.168.1.100 -u=deploy -i=~/.ssh/prod-web.pem --host-desc="Production web node"
After that:
sshx --host-list
sshx --host-test=prod-web
sshx -h=prod-web "uname -a"
The settings file is ~/.sshx/settings.json and is written with 0600 permissions.
Open An Interactive Login
When you personally need a remote prompt on a host already stored in sshx, do
not copy it into ~/.ssh/config:
sshx login prod-web
sshx login prod-web --sudo
sshx login -h=prod-web --sudo
sshx login prod-web --dry-run --json
--sudo starts a privileged login shell after feeding the host sudo secret
from the OS keyring. Login requires a local TTY, is POSIX-only, and is not
part of the Agent/MCP contract.
Save A Sudo Password
For commands that start with sudo, sshx can read a password from the OS
keyring (or the explicit local vault) and feed it to sudo through stdin.
sshx --password-set=prod-web-sudo
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl status nginx"
Use interactive input. Avoid inline values such as --password-set=key:password; they can leak through shell history or process lists.
Preview Before Running
--dry-run explains how sshx interpreted the command without connecting, executing, reading keyring secrets, changing known_hosts, or writing host config.
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
Dry-run proves the local plan. It does not prove the remote command would succeed.
Host Management
Named hosts turn repetitive SSH details into a short, readable alias. They are useful when you manage several servers, when each server uses a different SSH key, or when an agent needs a stable host inventory.
Add Hosts
Interactive setup:
sshx --host-add
Command-line setup. Omit -pk unless you want a stored sudo key; the inventory
must not record the runtime fallback master.
sshx --host-add \
--host-name=prod-web \
-h=192.168.1.100 \
-p=22 \
-u=deploy \
-i=~/.ssh/prod-web.pem \
-pk=prod-web-sudo \
--host-desc="Production web node" \
--host-type=linux \
--bind=en0
Then run commands by alias:
sshx -h=prod-web "hostname && uptime"
Import from ~/.ssh/config
If you already maintain hosts in the OpenSSH client config, import them selectively instead of retyping them. Import is never all-or-nothing by default — you choose what enters ~/.sshx/settings.json.
Interactive selection:
sshx --host-import
sshx lists importable entries (with the resolved user@host:port and key) plus everything it skipped and why, then asks which entries to import (numbers, names, or all).
Non-interactive, by name (script/agent friendly, all-or-nothing). --json
requires the name list and emits sshx.hosts.v1:
sshx --host-import=web1,db1
sshx --host-import=web1,db1 --json
--host-add / --host-update / --host-remove / --host-test /
--host-test-all also honor --json with the same schema (success, host
identity, and error_kind on failed tests).
Import from a different config file:
sshx --host-import --ssh-config=~/work/ssh_config
Preview without writing anything:
sshx --host-import=web1 --dry-run --json
What is imported per entry: HostName (or the alias itself when absent), Port, User, IdentityFile (as the per-host key), BindAddress / BindInterface (as bind; first value wins), and a single-token ProxyJump when that alias is already a named sshx host (as via).
Pollution guards — the importer always skips:
- wildcard or negated patterns (
Host *,web-?,!pattern) — they are rules, not hosts; - aliases that already exist in settings;
- entries whose
host:portalready exists in settings (or duplicates an earlier entry in the same file); - options sshx does not support (
ForwardAgent, unmappedProxyJump, …) — shown asignored:so nothing disappears silently; - options from other blocks:
Host *defaults are never merged into imported entries; IdentityFilevalues containing%tokens (reported in a note).
Match blocks are ignored and Include directives are not followed; import from included files directly via --ssh-config=<path>.
Settings File
Host definitions live in ~/.sshx/settings.json.
{
"key": "/Users/alice/.ssh/id_rsa",
"hosts": [
{
"name": "prod-web",
"description": "Production web node",
"host": "192.168.1.100",
"port": "22",
"user": "deploy",
"key": "/Users/alice/.ssh/prod-web.pem",
"password_key": "prod-web-sudo",
"type": "linux",
"bind": "en0",
"via": "edge"
}
]
}
The top-level key is the default SSH private key. A per-host key overrides it for that host only.
Jump hosts
When the local machine cannot reach app but named host edge can, persist via on the target. sshx opens one SSH session to edge, then a direct-tcpip channel to app, for this invocation only. There is no local listen port and nothing to tear down after the process exits.
sshx --host-add --host-name=edge -h=100.64.0.10 -u=jump
sshx --host-add --host-name=app -h=10.0.0.5 -u=app --via=edge
sshx -h=app --json "uptime"
sshx -h=app --via= # force a direct connection for one run
Each hop verifies its own host key and uses its own key or ssh_password_key. Secrets stay local. --via=name on a command overrides inventory for that invocation. Removing a host that other hosts still use as via is rejected.
Daily Host Commands
# List configured hosts
sshx --host-list
# Test one host
sshx --host-test=prod-web
# Test every host with a per-host dial timeout
sshx --host-test-all
# Update a host
sshx --host-update --host-name=prod-web -u=deploy -i=~/.ssh/prod-web-2026.pem
# Change or clear the persisted source bind
sshx --host-update --host-name=prod-web --bind=192.0.2.10
sshx --host-update --host-name=prod-web --bind=
# Remove a host
sshx --host-remove=old-lab
Practical Naming Patterns
Use names that explain both role and environment:
prod-web-1
prod-db-primary
staging-api
lab-router
customer-a-jump
Use password keys that do not expose sensitive topology in public logs. For shared runbooks, prefer placeholders:
sshx -h=prod-web -pk=<sudo-key> "sudo systemctl reload nginx"
Team And Agent Use
For human operators, named hosts reduce typing errors. For automation agents, they create a stable boundary:
- The agent receives
prod-web, not a raw IP and key path. - The operator can review
~/.sshx/settings.json. --dry-run --jsoncan confirm which address, port, user, key, and sudo key would be used.- Audit events can record the resolved host without storing secrets.
SFTP Workflows
sshx supports one-shot SFTP actions for common file tasks. It is not an interactive file manager; each invocation performs one clear upload, download, list, mkdir, or remove operation.
Plans and effect evidence
SFTP and server-to-server transfer accept --dry-run --json, --expect-plan,
--host-timeout and --global-timeout. Dry-run is offline and write-free.
Binding checks the public endpoints/operation and local upload payload, not
unread remote content or recursive directory membership.
Use --json to consume the CLI operation result, shared execution identity,
effects, change_state, nullable executed, and verification status.
Size/metadata evidence is not a content-digest guarantee. Downloads record local
writes even when the remote operation is risk=read; relay reads the source
and writes the destination.
The operation result includes entries, bytes_transferred, partial, and
directory_atomic (false). Each entry records its path/type/size/mode, copied
bytes, started/published state and verification; content-checked file copies
also expose source_sha256 / sha256. Known staging/cleanup evidence is
retained as staging_path / cleanup_error on errors. Use the actual entry verification rather than assuming
every operation (such as listing) has content hashes.
Upload/relay stage each file before publication. Overwriting an existing remote
destination requires supported atomic replacement; when unavailable, fail
without deleting that destination. Ordinary SFTP rename can publish a new
destination. Downloads stage beside the local destination and use the local
OS’s rename semantics; POSIX guarantees are not assumed on Windows.
After connection loss, remote staging cleanup may be impossible and is reported.
The publication methods are posix_rename, new-file sftp_rename_no_replace,
and download local_rename. Successful file verification includes post-copy
readback SHA-256 and metadata; mkdir verifies type/existence, removal verifies
absence, and listing supplies metadata only. Earlier published entries remain
in a partial result if a later directory entry fails.
Single-file staging, where supported, does not provide directory-wide atomicity, arbitrary-writer CAS, or automatic rollback. A failed/interrupted recursive transfer can have partial destination effects; inspect before retrying. See Plans, Outcomes, and Safe Retries.
Upload A File
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf
To overwrite an existing remote file with a backup and hash precondition, use Guarded File Apply instead of assembling upload + install yourself:
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json
sshx run --target=prod-web --json -- "sudo nginx -t"
Download A File
sshx -h=prod-web --download=/var/log/nginx/error.log --to=./error.log
Incident collection example:
mkdir -p incident-2026-07-01/prod-web
sshx -h=prod-web --download=/var/log/nginx/error.log --to=incident-2026-07-01/prod-web/error.log
sshx -h=prod-web --download=/etc/os-release --to=incident-2026-07-01/prod-web/os-release
List And Create Directories
sshx -h=prod-web --list=/var/log
sshx -h=prod-web --mkdir=/tmp/sshx-upload
Remove A Remote File
sshx -h=prod-web --rm=/tmp/old-upload.txt
Treat remote deletion as production change. Prefer listing the parent directory first:
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/old-upload.txt
Path Boundary
Local paths use your local operating system rules. Remote paths are SFTP paths and should be written as slash-separated remote paths, even when sshx is run from Windows.
# Local Windows path, remote POSIX path
sshx -h=prod-web --upload=C:\Users\alice\release.zip --to=/tmp/release.zip
When To Use Plain SSH Instead
Use an SSH command when the operation needs remote validation or privilege changes:
sshx -h=prod-web "sudo ls -l /etc/nginx"
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
Use SFTP for direct file movement. Use remote commands for checks, ownership changes, service reloads, and cleanup that requires sudo.
Guarded File Apply
sshx apply replaces one remote regular file. It is the file equivalent of sshx sql: classify the target, check a hash precondition, write a backup, then atomically replace the file. Reload and restart stay outside this command.
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \
--expect-sha256=<current> --sudo --json
What Apply Does
- Refuse anything that is not a clean absolute regular-file path.
- Block
/etc/passwd,/etc/shadow, and/etc/sudoersunless--force --bypass-reason=is explicit. - Read the current file (if it exists) and compare
--expect-sha256when provided. - Copy the current file to
~/.sshx/file-backups/unless--no-backup --forceis set. - Write a same-directory temp file, preserve mode and owner, then rename over the target.
- Return hash/backup evidence,
change_state, nullableexecuted,verified,verification, conditions andcompletion, alongside legacychanged/createdfields.
If the remote content already matches the payload, apply succeeds with changed=false and does not write a backup.
Verification and concurrency limits
Use the shared change_state (changed|unchanged|unknown) on failures; an old
boolean changed=false must not be interpreted as proof that no write occurred.
A no-op can be verified and unchanged without a mutation. A post-write
verification_failed can mean replacement happened but readback failed or
did not match: inspect before/after/payload hashes and any known backup.path
before retrying. A backup is recovery evidence, not automatic rollback.
backup.verified records backup readback/hash verification;
rollback_available requires that verification, not merely an existing path.
It still does not mean a tested restore or an automatic rollback. Owned staging
artifacts are cleaned up best-effort; cleanup_pending names possible leftovers after interrupted or
failed cleanup. Inspect those paths before removing anything.
| Observation | Execution/change evidence |
|---|---|
| Content already matches | executed=false, change_state=unchanged, verified=true |
| Publication acknowledged, required readback fails | executed=true, completion=completed, change_state=changed; operation fails verification |
| Rename acknowledgement missing | executed=null, completion=unknown, change_state=unknown; inspect before retry |
When publication is attempted, replace_method identifies posix_rename,
sftp_rename, or privileged same_directory_mv. Do not infer a stronger
primitive than the reported method.
The target hash is checked close to replacement, but SFTP has no general atomic
compare-and-swap against arbitrary concurrent writers. Atomic rename depends
on the server primitive; do not infer delete-then-create safety or filesystem
durability from a successful SSH exit. Privileged apply likewise requires a
valid readback report, not just exit zero.
If the POSIX-rename extension returns SSH_FX_OP_UNSUPPORTED, apply may try
ordinary rename, which can refuse an existing destination. It never upgrades
that failure into delete-then-create replacement.
--force retains its old hash-precondition bypass and backup/critical-path
approval meanings. It cannot bypass --expect-plan.
Privileged Paths
SFTP runs as the SSH user. Use --sudo when the target is not writable by that user. sshx stages the payload under the remote home directory, then runs a privileged stdin script to install it. The script is never left on the host.
Before writing a backup or replacement temp, apply checks that the target’s
parent directory is writable and searchable by the SSH user. Atomic replacement
requires both write and execute permission on that directory, regardless of
the target file’s owner. A denial returns
error_kind: parent_directory_not_writable; when a sudo password key is
configured, the error suggests --sudo. This preflight cannot prevent a
concurrent permission change after the check.
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --sudo --json
Validation and service reload are separate sshx run invocations:
sshx run --target=prod-web --json -- "sudo nginx -t"
sshx run --target=prod-web --json -- "sudo systemctl reload nginx"
Preview
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json
Dry-run hashes the local file and prints the local plan. It does not connect or mutate the remote file.
For strict local review-to-execution binding, inspect the nested
sshx.plan.v1 / plan_hash and repeat with --expect-plan="$reviewed_hash".
Payload bytes and public identity/trust must still match; remote contents are
governed by explicit conditions, not implicitly frozen by the plan.
See Plans, Outcomes, and Safe Retries.
When To Keep Using SFTP
Use --upload / --download for moving bytes without a backup contract. Use
apply for replacement with a hash, backup, and explicit change/verification
evidence, including uncertainty after failures.
Text Dissection
sshx text is the Agent-facing way to extract exception blocks and error
lines from a remote file or systemd journal. It is not a remote grep
wrapper and does not accept --command.
sshx text --help
sshx text --help --json
sshx text -h=prod-web --path=/var/log/app.log --preset=exception --json
sshx text -h=prod-web --path=/var/log/app.log --around-line=8821 --context=5 --json
sshx text -h=prod-web --journal=nginx.service --since=1h --preset=error --json
Workflow
- Read
sshx text --help(or--help --json) for sources, presets, and bounds. - Take exception blocks and counts first:
--preset=exception --json. - Slice a hit with
--around-lineand--context. - Download whole files only when you need an incident archive.
Sources
Exactly one of:
--path=/abs/file— SFTP stream of a regular file. Default--scan=endreads the last 8MiB. Symlinks, directories, and binary files are refused.--journal=UNIT— sshx-ownedjournalctlargv (--no-pager,--output=short-iso,--unit, optional--since/--until). The Agent does not compose journalctl flags.
--sudo uses sudo -S with the host sudo keyring secret on stdin.
Result
JSON schema sshx.text.v1. Branch on success, hits[].kind,
stats.total_hits vs stats.returned, truncated, truncated_reason,
redacted, and line_origin (file or scanned_window). Secret-shaped
spans are redacted unless --no-redact. Audit events omit hit text.
stats also carries the window accounting a caller needs to judge coverage:
| Field | Meaning |
|---|---|
file_size | Remote file size when the source is a file (omitted for journal) |
window_start_byte | First byte the window reads |
expected_scan_bytes | Window budget: the window capped by --max-scan-bytes |
expected_scan_bytes is additive and omitted when the source size is unknown
(journal). Compare it with bytes_scanned: a scan that stopped early reports
less than it expected, and a scan that hit its budget sets
truncated_reason=max_scan_bytes with total_hits_exact=false.
Monitoring
A wide window over a slow link used to look like a hang. sshx text now
narrates progress on stderr while it scans:
sshx text: scanning 2.0MiB/2097152B of 61.3MiB/64296766B (3%) lines=10240 elapsed=4.1s matches=0
sshx text: warning: scan stopped at its --max-scan-bytes budget after 12.0s (8.0MiB/8388608B scanned, 8.0MiB/8388608B in window); results are partial (total_hits_exact=false). Narrow with --offset=N or --tail=N, pre-filter with --pattern=..., or raise --max-scan-bytes=N.
- Progress appears only after a grace period, at most once per second, and never for a scan that finishes quickly.
- A scan that stops at its byte budget always warns, so partial results are
never mistaken for complete ones. A slow scan closes with the same
--offset/--tailadvice. - stdout stays exactly one JSON document. Progress and advice go to stderr
only, so
--jsonparsing is unaffected.
# Narrow the window instead of scanning 8MiB to answer a small question
sshx text -h=prod-web --path=/var/log/app.log --offset=41000 --limit=200 --json
Agent And Script Mode
sshx is designed to be called by scripts and AI agents. The contract is intentionally simple: predictable streams, predictable exit codes, optional JSON, and optional local audit events.
Canonical Run Contract
Prefer sshx run for strict alias selection, complex scripts, and multi-host fan-out:
sshx run --target=prod-web --json -- "systemctl is-active nginx"
sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime"
sshx run --target=prod-web --script-file=./check.sh --dry-run --json
cat ./check.sh | sshx run --target=prod-web --script-stdin --json
- Selectors resolve configured hosts only. Use
--address=for one literal address. - Script payloads are streamed on SSH stdin and are not reconstructed through shell joining.
--sudoruns the selected script interpreter as a whole via sudo. Do not embed sudo commands in a script: the script occupies stdin, so sshx cannot inject the password for nested sudo.- The script’s
#!line selects the interpreter, so a#!/usr/bin/env bashpayload keeps bash semantics (set -o pipefail, arrays,[[ ]]). Use--shell=NAMEto override it. Supported:sh,bash,zsh,dash,ksh,ash; any other interpreter is rejected aserror_kind: configwithout connecting. The choice appears asaction.script_runner. - Dry-run and results expose payload SHA-256 and byte length, not raw script contents.
- Multi-target
--jsonlstreamsrun_started, per-target events, andrun_finished. - Multi-target exit codes:
0all succeeded,1partial/failed/skipped/uncertain,255request-level failure. - High-risk bypasses require explicit flags; command mode and
sshx runrequire--bypass-reason=with--force/--no-safety-check. - Working-directory
.envfiles are not loaded. InheritedSSH_FORCE/SSH_NO_SAFETY_CHECK/ host-key env switches do not authorize trust relaxation.
Default Stream Behavior
By default sshx does not request a PTY. That keeps stdout and stderr separate and avoids terminal control characters in script output.
sshx -h=prod-web "systemctl is-active nginx"
The remote command exit code becomes the sshx process exit code when the remote command runs.
Exit Codes
| Code | Meaning |
|---|---|
0 | Remote command succeeded. |
1..254 | Remote command failed with that exit code. |
255 | sshx failed before or around execution, such as connect, auth, host-key, timeout, blocked command, config, or other local error. |
In JSON mode, sshx-level failures use exit_code: -1 and a non-empty error_kind, so automation can distinguish them from a remote command that exits 255.
JSON Output
sshx -h=prod-web --json "systemctl is-active nginx"
Example shape:
{
"host": "192.168.1.100",
"port": "22",
"user": "deploy",
"command": "systemctl is-active nginx",
"exit_code": 0,
"success": true,
"stdout": "active\n",
"stderr": "",
"duration_ms": 142,
"auth_method": "key"
}
Agent branching example:
result="$(sshx -h=prod-web --json "systemctl is-active nginx")"
if printf '%s' "$result" | jq -e '.success == true' >/dev/null; then
echo "nginx is active"
else
printf '%s\n' "$result" | jq '{exit_code, error_kind, stderr}'
fi
Guarded File Apply
Prefer sshx apply when replacing one remote regular file. Branch on
change_state, executed, verified, verification, completion, and
error_kind. Legacy changed is not proof of no change on an error. A
precondition failure before commit means no target write; post-write
verification_failed requires inspecting hashes/backup before retrying.
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --expect-sha256="$current" --sudo --json
Reload stays a separate sshx run. See Guarded File Apply.
Reusable Host Inspection
Before repeating a chain of discovery commands, list and run a bounded inspection capability:
sshx plugin list --json
sshx inspect -h=prod-web system.baseline --json
Application-specific collectors are sshx runtime assets, not skill assets. An Agent can scaffold one without inventing its file layout:
sshx plugin create docker.environment --template=docker --privilege=optional --json
sshx plugin test docker.environment --fixture=complete --json
sshx plugin trust docker.environment --json
sshx inspect -h=prod-web docker.environment --json
Branch on observation status (complete, partial, unsupported, or
failed) and typed errors. Do not interpret permission-limited partial as
service absence. New or edited plugins must be trusted by digest before sshx
connects to the target.
Remote reuse is explicit:
sshx inspect -h=prod-web docker.environment \
--cache=remote-prefer --max-age=10m --json
The remote cache stores only normalized, redacted observation JSON. It is host-scoped and freshness-bounded, not an authoritative inventory. See Inspection Capabilities And Local Plugins for the full manifest, trust, redaction, and invalidation contract.
Dry-Run For Change Review
Before a script performs a privileged operation, ask for the plan:
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
Use dry-run to verify host resolution, selected sudo key, safety status, and whether the command would mutate state. Do not treat it as proof that the remote service can restart successfully.
For command/run, apply, SQL, SFTP, transfer and inspect, review the nested
sshx.plan.v1 and top-level plan_hash/risk. If plan.bindable is true,
repeat the invocation with --expect-plan="$reviewed_hash". It rejects local
drift before secrets/network; it does not freeze remote state. Unknown commands
and scripts retain unknown effects with mutation risk, regardless of caller
intent. See Plans, Outcomes, and Safe Retries for public
identity requirements, trust-store invalidation and the outcome decision table.
Timeouts
Always set timeouts for unattended workflows. sshx run defaults the command
timeout to 60s when --timeout / SSH_TIMEOUT are unset; compatibility
sshx -h=... command mode still has no command timeout unless you set one.
The SSH dial timeout is independent (30s).
sshx -h=prod-web --timeout=30s --json "systemctl is-active nginx"
sshx -h=prod-web --timeout=2m --json "sudo apt-get update"
sshx run --target=prod-web --json -- "uptime" # command timeout defaults to 60s
Add --host-timeout=2m to limit the whole admitted target, or
--global-timeout=5m for the operation including queue time. These are opt-in;
the run’s existing derived aggregate budget remains unless overridden.
Cancellation closes local transports but does not guarantee remote termination
or rollback. Native secret-backend calls may remain noninterruptible.
Fan-out supports --fail-fast (the --failure-mode=fail_fast alias) and
--max-failures=N. Thresholds stop new admission only; active targets finish
and can add failures. Never-started targets must be distinguished from
cancelled or uncertain work; do not retry a mutation on transport error alone.
Audit Events
Non-dry-run invocations write local JSONL audit events by default:
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
Store audit events next to a project or incident directory:
sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
Audit events are for provenance. They record metadata and outcomes, but they do not record plaintext passwords, private key contents, stdout, or stderr.
PTY Is Explicit
Some commands need terminal behavior:
sshx -h=prod-web --pty "top -b -n1"
Do not combine --pty with --json. A PTY merges stderr into stdout and makes structured automation less reliable.
Inspection Capabilities And Local Plugins
Repeated host discovery is expensive for an Agent: checking whether Docker is
available, locating Compose projects, reading routes and DNS state, and deciding
whether a missing result means “absent” or “permission denied” otherwise takes
many independent commands. sshx inspect turns that work into one versioned,
structured invocation.
Built-in system capabilities
The stable operating-system layer is compiled into sshx:
system.identitysystem.resourcessystem.baselinenetwork.interfacesnetwork.routesnetwork.dnsnetwork.listenersnetwork.firewall
Run the complete baseline:
sshx inspect -h=prod-web system.baseline --json
The result is an sshx.observation/v1 JSON document. status is one of
complete, partial, unsupported, or failed; permission-limited evidence
is never normalized to an absent service.
Plugins belong to the sshx runtime
Application collectors are local sshx assets. They do not belong in an Agent skill and are not installed on the target:
~/.sshx/
├── settings.json
├── audit/
├── plugins/
│ └── <plugin-id>/
│ ├── manifest.json
│ ├── collectors/
│ ├── result.schema.json
│ ├── README.md
│ └── fixtures/
├── plugin-lock.json
└── observations/
Set SSHX_HOME to replace ~/.sshx for an isolated project, Agent, or CI run.
Existing settings, audit, plugin, and lock paths all follow the same runtime
root.
Create a custom plugin
plugin create produces a complete, editable scaffold:
sshx plugin create private.environment \
--runner=sh \
--platform=linux \
--privilege=optional \
--template=generic \
--json
Available templates are generic, docker, and nginx. The Docker template
collects installation/daemon state, versions, Docker root/storage/cgroup data,
containers, images, ports, networks, mounts, and Compose project metadata. It
does not collect container environment values, registry auth, .env contents,
Secret values, or raw Compose files.
Plugin API v1 uses the sh runner on Linux or Darwin targets. The sshx
controller itself remains cross-platform; a future Windows-target runner needs
an explicit execution and test contract rather than silently treating
PowerShell as POSIX shell.
Use --replace only when replacement is intentional. sshx moves the previous
directory under ~/.sshx/plugin-backups/ before installing the new scaffold.
plugin remove is recoverable for the same reason.
Validate, test, and trust
sshx plugin validate private.environment --json
sshx plugin test private.environment --fixture=complete --json
sshx plugin test private.environment --json
sshx plugin trust private.environment --json
sshx plugin show private.environment --json
sshx plugin list --json
validate checks the manifest contract, paths, file types/permissions, entrypoint,
JSON Schema, timeouts, privilege declaration, cache policy, and declared effects.
test validates a fixture or explicitly runs the local collector with bounded
stdout/stderr and a minimal environment.
A new or changed local plugin is untrusted. plugin trust records the digest of
the manifest, entrypoint, and schema in plugin-lock.json. Any later edit changes
the digest, and inspect refuses to open an SSH connection until the new digest
is explicitly trusted. Trust is admission and audit metadata, not a sandbox: a
trusted collector can do anything allowed to the SSH identity, so review it first.
Execute without installing remote code
sshx inspect -h=prod-web private.environment --json
sshx resolves and verifies the plugin locally before connecting. It streams the
collector to a fixed sh -s -- session over SSH stdin, validates exactly one
JSON document against the plugin schema, applies field redaction, wraps the facts
with target/provenance/freshness metadata, and exits. The collector is never
installed persistently on the target and never receives SSH or keyring secrets.
Privilege is declared by the manifest:
never:--sudois rejected.optional: normal user by default; use--sudoexplicitly when necessary.required: sshx resolves the selected sudo key and feeds it to sudo separately from the collector payload.
Preview every boundary without connecting:
sshx inspect -h=prod-web private.environment \
--cache=remote-prefer \
--dry-run \
--json
The plan includes the plugin path, digest, trust state, host resolution, privilege, secret-read decision, execution decision, known-host impact, and observation write.
Freshness-bounded remote observations
Remote caching is opt-in:
sshx inspect -h=prod-web private.environment \
--cache=remote-prefer \
--max-age=10m \
--json
Only the normalized, redacted JSON observation is stored under the remote user’s
~/.sshx/observations/v1/. Plugin code remains local. Files are owner-only and
replaced atomically.
A snapshot is reusable only when its capability ID/version/digest, result schema,
parameters, target host-key fingerprint, platform, authenticated UID, boot ID,
and privilege scope still match. TTL expiry or --refresh runs the collector again. --allow-stale
is an explicit instruction to return a matching expired snapshot; it never makes
the snapshot appear fresh.
Cached files are untrusted input. sshx rejects symlinked path components, unsafe permissions, wrong ownership, oversized files, malformed JSON, mismatched schemas, and identity drift rather than silently treating them as current facts.
This is an observation cache, not a CMDB: it has no fleet search, ownership, desired state, reconciliation, or claim of authoritative inventory.
MCP Server (stdio)
sshx mcp serves the sshx execution contract over the Model Context Protocol
so MCP-capable agents (Claude Desktop, IDE agents, custom clients) can call
sshx as native tools instead of shelling out.
sshx mcp
The server speaks MCP over stdio only. It is spawned and owned by the MCP client, holds no SSH connections, keeps no state, and exits when the client closes the stream. Every tool call re-enters the sshx binary as a one-shot child process — the same process model, safety gates, keyring access, and audit trail as the CLI.
Client Configuration
Claude Desktop / generic MCP client entry:
{
"mcpServers": {
"sshx": {
"command": "sshx",
"args": ["mcp"]
}
}
}
Tools
| Tool | Maps to | Notes |
|---|---|---|
sshx_run | sshx run --json | Selectors, command or byte-preserving script (shebang or shell selects the interpreter), bounded fan-out, dry-run, force + bypass_reason |
sshx_sql | sshx sql --json | Guarded single-statement SQL via remote psql/sqlite3/mysql |
sshx_apply | sshx apply --json | Guarded single-file replace; accepts from_path or inline content (including an empty string) |
sshx_inspect | sshx inspect --json | Built-in capabilities and trusted local plugins |
sshx_text | sshx text --json | Bounded file/journal dissection; exception blocks, presets, line windows |
sshx_sftp | SFTP flags | upload / download / list / mkdir / remove |
sshx_transfer | --transfer | Server-to-server streaming through the local machine |
sshx_host_list | --host-list --json | Read-only sshx.hosts.v1 inventory |
For row-filtered SQL, a backup that would widen to a full-table snapshot is
blocked by default. Set allow_full_table_backup: true on sshx_sql only when
that broader before-image is intentional; this does not authorize an
UPDATE/DELETE without WHERE.
Tool results contain the CLI’s versioned JSON verbatim (for example
sshx.result.v1 from sshx_run), so success, error_kind, completion,
and retry guidance keep exactly the semantics documented for the CLI. A
non-zero child exit marks the MCP result as a tool error while preserving the
structured payload.
When the MCP client supplies a progressToken on sshx_run, the server runs
the child with --jsonl and offers target_finished events as bounded,
best-effort MCP notifications/progress notifications. Progress total is the selected target
count. The final tool document stays the CLI result: a single-target call still
returns sshx.result.v1; multi-target calls still return the JSONL stream.
--pty is permanently out of scope for MCP. A PTY merges stderr into stdout
and is incompatible with --json. Agents should keep using sshx_run /
sshx_sql / sshx_apply rather than an interactive terminal.
Bound plans, deadlines, and delivery
Remote tools accept expect_plan, host_timeout_seconds, and
global_timeout_seconds, matching the CLI flags. timeout_seconds keeps its
command/session meaning. Run also accepts fail_fast and max_failures;
these stop new admission only, not already active targets. Opaque
commands/scripts remain mutation risk regardless of caller intent.
SQL accepts optional bypass_reason to record existing policy approvals;
this does not introduce a new mandatory reason for old SQL invocations.
Run’s optional request_id is caller correlation, not the unique
execution_id. Inspection supports the same dry_run preview flow.
Preview with dry_run: true, inspect plan.bindable / plan.unresolved, and
execute the same inputs with the reviewed expect_plan. Inline payload staging
paths are not payload identity; bytes are. Public IP/key/trust requirements,
conservative whole-trust-store invalidation, and remote SQL discovery
restrictions are the same as the CLI. Common execution IDs, fingerprint,
change state and verification are CLI facts, not an MCP-specific schema.
The adapter has a separate 30-minute process watchdog. A shorter explicit global timeout receives two minutes of reporting grace, capped by that watchdog; a command timeout is not multiplied into a new fan-out limit. Cancellation requests cooperative child shutdown with bounded escalation. Platform-specific local process shutdown does not prove remote termination. Escalation follows a three-second grace period and targets the owned process tree (Unix process group or Windows job object), not unrelated processes.
Progress delivery is bounded independently of stdout draining; progress notifications are not the authoritative completion record. Malformed, oversized or truncated JSONL, missing final completion, or output-delivery failures are adapter errors, not successful synthesized results. Keep the execution outcome separate from an adapter failure before deciding to retry. See Plans, Outcomes, and Safe Retries. Current adapter caps are 64 MiB stdout/individual event records, 1 MiB stderr, 100,000 retained events, and a 64-entry progress queue. Progress write/delivery has a 250 ms grace; a stalled stdio session closes instead of leaving a blocked SDK writer. These are delivery limits, not remote output or effect verification guarantees.
Security Model
- Same gates, same evidence. Safety checks, host-key verification, keyring
credential roles, and audit all run in the child process exactly as in
direct CLI use. Run’s
force/no_safety_checkrequire explicitbypass_reason; apply retains its critical-path reason requirement and SQL retains its existing approval flags with an optional recorded reason. - Audit attribution. Child invocations carry
entry: "mcp"in their audit events, so MCP-originated executions are distinguishable from interactive CLI use. The marker is metadata only — it never changes trust or safety decisions. - No secret surface. Password management (
--password-setand friends) is deliberately not exposed as a tool. Configure credentials with the CLI first; MCP tools only ever reference keyring keys. - No trust relaxations by omission. Accepting unknown host keys is not a
tool parameter. Trust hosts explicitly beforehand (for example with
sshx --host-testor one supervised CLI run). - stdio only. There is no HTTP/SSE transport, no listening socket, and no resident service; this boundary is documented in AGENT.md §3.
Typical Flow
- Configure and trust hosts with the CLI (
--host-add,--host-import,--host-test). - Store credentials in the OS keyring (
--password-set=...). - Point the MCP client at
sshx mcp. - The agent discovers inventory (
sshx_host_list), previews withdry_run: true, executes, and branches on the structured result.
Audit Query and Export
sshx writes one JSONL audit event per non-dry-run invocation under
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl (or --audit-output=<dir>).
sshx audit is a read-only consumption surface. It does not rotate,
delete, upload, or rewrite audit files, and it does not write an audit event
for the query itself.
Query
sshx audit query --json
sshx audit query --since=2026-09-01 --until=2026-09-02 --target=prod-web
sshx audit query --run-id=<id> --error-kind=blocked --bypass-only --json
sshx audit query --execution-id=<id> --json
Filters compose (AND):
| Flag | Matches |
|---|---|
--since= / --until= | RFC3339 or YYYY-MM-DD on timestamp |
--target= | host_input, host_resolved, or host_name |
--action= | action or mode |
--run-id= | run_id or event_id |
--execution-id= | execution_id or parent_execution_id (independent of legacy run IDs) |
--error-kind= | outcome.error_kind |
--bypass-only | force or a non-empty bypass_reason |
Empty results exit 0. --json emits sshx.audit.query.v1 with success,
count, and events (an empty array when nothing matches).
Export
sshx audit export --to=./incident.jsonl --run-id=<id>
sshx audit export --to=./siem.jsonl --since=2026-09-01 --json
Writes matching events as JSONL to --to=. sshx is not a SIEM; export is a
bounded handoff file with owner-only permissions.
Integrity and persistence
Results and audit share finalized plan/execution identity and redacted outcome
evidence. execution_fingerprint excludes raw stdout/stderr and secrets; it is
not a digital signature or a tamper-proof log guarantee.
Audit can record public credential-role references (ssh_password_key,
sql_password_key, sql_user), observed peer_address /
host_key_fingerprint, and cancellation_cause, deadline_scope, stop_reason.
Absent peer evidence is not proof that a connection occurred. None of these
fields contains the referenced password.
Apply audit preserves observed apply_expect_sha256, apply_payload_sha256
(including an empty payload), before/after hashes and backup path. When an
outcome exists, apply_backup_verified is an explicit boolean; apply_mode,
apply_uid, apply_gid, apply_cleanup_pending and apply_replace_method
retain ownership, cleanup and publication evidence. SQL’s engine-specific
evidence is nested under sql_evidence. Shared cancellation/deadline fields
come from the same finalized metadata, not a separate audit inference.
Malformed/partial JSONL records are skipped with visible diagnostics while
valid records remain available. Do not equate a damaged file with a clean empty
query. File read/scan and export-write failures are errors, not successful
zero-match results. Valid unknown fields are retained by query/export.
JSON includes nonzero skipped_records and warnings when needed; warnings
identify the file and line with error_kind: audit_record_invalid|local_io,
without including malformed record content. Skipping malformed, non-object or
partial records alone retains success=true and the valid records.
The legacy flat query error remains error_kind: config;
error_details.kind adds the owned I/O classification without breaking it.
On read/scan/write failure, success=false can accompany retained valid
events; do not discard those records or treat the partial response as complete.
Other readable files are still processed. Valid raw unknown fields and values
are preserved unchanged in export.
Execution audit writing remains best-effort. Persistence status/warnings are
separate from the execution outcome and do not turn a successful mutation into
a failed operation to retry. Repair the audit destination independently.
Lifecycle JSON reports audit_status: written|failed|disabled; this status is
not part of the execution fingerprint.
See the outcome decision table.
sshx Contract Freeze Policy
Agent ecosystems punish interface drift. This document is the public commitment
for what is frozen in the v1 schemas and when a change requires v2.
Frozen v1 surface
Within a major schema version, changes are additive-only. Removing,
renaming, or changing the meaning of a field, flag, exit code, or
error_kind is a breaking change.
| Surface | Frozen names | Notes |
|---|---|---|
| Request schema | sshx.request.v1 | sshx run request envelope |
| Run result schema | sshx.result.v1 | Single-target run; compatibility command JSON retains its legacy fields |
| Plan schema | sshx.plan.v1 | Nested in existing previews; does not replace sshx.request.v1 |
| Event schema | sshx.event.v1 | JSONL run_started / target_started / target_finished / run_finished |
| Host inventory | sshx.hosts.v1 | --host-list and host mutation/test JSON |
| Secrets | sshx.secrets.v1 | --password-check / --password-list / --password-set JSON |
| Audit events | sshx.audit.v1 | One JSONL object per non-dry-run invocation |
| Audit query | sshx.audit.query.v1 | sshx audit query/export --json |
| Text dissection | sshx.text.v1 | sshx text --json; help document is sshx.text.help.v1 |
| Exit codes | 0, 1..254, 255 | Remote status vs sshx-level failure |
| JSON sshx failure | exit_code: -1 | Distinguishes a remote exit 255 |
error_kind | timeout, auth, host_key, connect, blocked, exit_missing, config, error, plus SQL/apply additions including unreproducible_select, full_table_backup_requires_opt_in, and parent_directory_not_writable | Branch on this field, not prose |
| JSONL event types | run_started, target_started, target_finished, run_finished |
CLI flags listed in sshx --help for a released minor version remain valid
for the rest of that major line. New flags may be added. Required flags must
not be introduced for an existing invocation shape.
Compatibility policy
- Additive fields may appear on v1 documents at any time. Agents must ignore unknown fields.
- A breaking change requires a new schema (
sshx.result.v2, …) and an N-1 support window: the previous schema remains emitted or accepted until the next major sshx release after the new schema ships. --jsonstdout stays a machine document. Human logs belong on stderr, and sshx never writes human text to stdout, including on failure.- stderr carries human notices (deprecation warnings, narration, progress) and,
outside
--json, the diagnostic for a failed invocation.--quiet(--no-notices) suppresses the notices; with it, a caller that merges the streams (2>&1) under--jsonstill reads exactly one document, in both the success and the failure path. Merging stderr without--quietis not supported for parsing. - Per-verb discovery is part of the contract: every subcommand answers
sshx <verb> --help, andsshx <verb> --help --jsonemits the same blocks as ansshx.help.v1document (sshx text --help --jsonkeeps its structuredsshx.text.help.v1document). --helpand--quietare recognized in option position, in any order:sshx -h=<host> -p=22 --helpprints the global usage without resolving a host, connecting, or touching the trust store. Once the remote command, SQL statement, or--separator starts, a later--help/--quietbelongs to the payload.- MCP tools return the CLI JSON verbatim. MCP does not grow a parallel schema.
Additive execution hardening
Plans, Outcomes, and Safe Retries defines
--expect-plan, scalar risk and effects, execution identity/fingerprint,
nullable execution observation, verification, deadlines and safe retry decisions.
These fields do not redefine legacy intent, changed, force, completion,
or exit status. New plan_mismatch, plan_unresolved, cancelled, and
verification_failed categories are additive; parse categories, not error prose.
Do not assume every historical JSON response had a schema tag or the same envelope. Run, apply, SQL, inspection, compatibility command and file operations retain their domain-specific payloads. Common metadata is additive, not a replacement JSON object. JSONL event names remain unchanged.
1.0 evidence gates
1.0 is not a date or a blanket checked box. Each gate needs evidence:
| Gate | Evidence and remaining boundary |
|---|---|
| CLI/MCP contract parity | internal/app/mcp_test.go, tests/e2e/mcp_e2e_test.go; malformed streams and shutdown require their own tests |
| Windows correctness | Native CI jobs, not a cross-build; POSIX permissions, login and signals are not Windows guarantees |
| Secret backend lifecycle | internal/keyringstore plus isolated native-keyring runs; the e2e backend does not prove native integration |
| Frozen JSON/JSONL and exits | Contract fixtures and compiled-binary tests; legacy envelopes must remain consumable |
| Mutation/recovery evidence | Real file/SQL state and backup checks; mocked SQL counts do not prove engine transactions |
See the acceptance matrix for concrete test paths and named prerequisites. A test existing in the tree does not mean it ran on every platform.
Until 1.0, minor versions may still add first-level capabilities. They must not break the frozen names in this document.
Plans, Outcomes, and Safe Retries
This is the additive execution-hardening contract for issue #71. Existing flags, intent/force meanings, result envelopes, exit codes, and JSONL event names remain valid. See Contract Freeze Policy for compatibility and the acceptance matrix for evidence and external prerequisites.
Review and bind a local plan
Noninteractive command, run, apply, sql, SFTP, transfer, and inspect
previews expose a nested plan with schema_version: "sshx.plan.v1", plus
plan_hash and risk. Run previews retain their sshx.request.v1 envelope.
sshx run --target=prod-web --script-file=./deploy.sh --dry-run --json
# Review plan.bindable, plan.unresolved, risk, effects, targets and inputs.
# Paste the reviewed sha256:<64 lowercase hex characters> value unchanged:
sshx run --target=prod-web --script-file=./deploy.sh \
--expect-plan="$reviewed_hash" --json
--expect-plan is optional. It compares the prepared invocation against the
reviewed hash before secret lookup or network work. It is not an approval
service; the caller must protect the reviewed hash. A malformed digest is
config, a different digest is plan_mismatch, and a matching but unbindable
plan is plan_unresolved. --force cannot bypass this comparison. Rejection
may write the normal redacted audit event.
Ordinary --dry-run remains offline, secret-free, and write-free, including
no audit, trust, cache, or payload-spool writes. It reads public configuration,
public key/trust material and local payload bytes; it does not read private keys
or consult the secret backend for values.
What the hash binds
The hash is sha256: over a canonical JSON semantic projection, not over the
displayed JSON. Targets and map keys are sorted; meaningful command/script/SQL
bytes remain significant. Durations use normalized integer nanoseconds. Schema
and execution-semantics versions are part of the projection.
Included inputs cover resolved endpoint roles, addresses/users/ports/source
bind, public credential identity and role references, trust snapshot, payload
digests/lengths, interpreter/PTY/privilege, paths and mutation destinations,
SQL classification/identity/backup policy, inspection plugin digest/cache
policy, safety bypasses, conditions, and effective execution/failure limits.
IDs, timestamps, formatting, audit destination, raw output, passwords and private
keys are not semantic plan inputs. A local payload source path is not an identity
when identical snapshotted bytes define the operation; a download destination is.
selector_digest still describes selection, not this broader execution binding.
Trust invalidation is intentionally conservative: the current implementation
hashes the sorted nonblank, noncomment records of the entire selected
known_hosts file, not just records for the selected host. An unrelated trust
record edit can invalidate a reviewed plan. Bound execution uses the admitted
trust snapshot rather than reopening a changed file.
When a preview is unbindable
- DNS-only targets: offline preview does not resolve or freeze DNS answers. Use a configured literal IP for binding.
- Key authentication without a usable
<private-key-path>.pubsidecar: preview cannot derive public identity by reading the private key. The loaded signer is checked against the admitted public-key fingerprint. - Missing/unavailable trust material, untrusted peers, or relaxed host-key
settings (
--accept-unknown-host/--insecure-hostkey). A live peer-key rejection still has the establishedhost_keycategory. - SQL identity supplied only by remote discovery/client defaults. Specify the
database and role; container/
--db-cred-fromdiscovery is not offline-pinned and is rejected in bound mode.
Password-only binding describes the explicit principal, backend and credential reference, not the secret bytes. Secret rotation or a changed remote role policy is not frozen by a local hash. Unbound invocations retain their existing behavior.
Script/apply bytes and bound uploads execute from local snapshots. Remote rows, file contents, recursive directory membership, executable versions, permissions and observation-cache contents are not implicitly snapshotted by the plan. Remote preconditions run after local admission. The hash is not a remote lock.
Risk is not intent or authorization
risk is a scalar: read < mutation < privileged < destructive.
effects retains unknown, remote_write, local_write, privileged and
destructive facts so the scalar does not hide overlapping effects.
Opaque commands and scripts default to mutation with unknown effects;
passing --intent=read does not prove they are read-only. A sudo read is
privileged; removal is destructive; uploads/apply/relay write remotely.
Downloads can be remote read while explicitly writing a local destination.
Inspection with remote caching includes cache writes; custom collectors are
not assumed pure. The existing safety gates and bypass requirements still
decide admission. Classification is neither permission nor a sandbox.
Interpret outcomes before retrying
Shared fields are additive to each verb’s existing result:
| Field | Meaning |
|---|---|
execution_id, parent_execution_id | Invocation/target correlation; caller request IDs and legacy run IDs retain their meanings |
plan_hash, risk, effects | Admitted public plan and anticipated effects |
execution_fingerprint | Digest of finalized redacted execution evidence, not a signature or replay-prevention token |
peers, target_fingerprints | Observed public peer/auth identities and, for a run parent, its finalized target fingerprints |
change_state | changed, unchanged, or unknown; unknown must not be treated as false |
executed | Nullable execution observation; null means not known, not “did not run” |
verified, verification | Whether required evidence was verified, and its status; process success alone is not effect verification |
preconditions, postconditions | Optional condition arrays with kind, subject, expected, observed, status |
Raw stdout/stderr, secret values, and presentation are not fingerprinted.
Fingerprints provide correlation against a trusted reference, not tamper-proof
audit storage. A result can be successful yet lack effect verification; a
failed result can describe a change that already happened. Apply’s old changed
and created booleans remain for compatibility; use change_state on failures.
Only the finalized shared projection is covered by the fingerprint; do not
assume every legacy envelope field is included. Reported peer/auth fields
outside that projection are not automatically bound by it.
The shared peers projection now binds actual transport address, host-key
fingerprint, auth method, effective user and SSH/sudo credential references.
Target fingerprints include these observed facts; the parent binds them
transitively through target_fingerprints. Collection occurs after successful
connection/authentication: failures before that point do not promise retained
observed peers. Missing address/key observations are not invented from planned
target settings, and credential references do not include secret values.
For run, the parent execution ID is also its run ID; target execution IDs are
derived deterministically from the parent and canonical target identity/index.
The single-target result uses the target’s metadata, not the run parent’s.
The parent run_finished metadata retains sorted finalized target fingerprints.
Caller request_id is correlation only.
When the JSONL writer remains writable, seq is contiguous starting at 1 and
every selected target gets one terminal event. Skipped, never-admitted targets
have no target_started event. Final counts satisfy:
Selected = Succeeded + Failed + Skipped
Started = admitted = Succeeded + Failed
Uncertain is a subset of Failed, not an additional total
Selector-rejected candidates are not included in the selected counts.
Uncertain completion includes partial, completed_unconfirmed and unknown
outcomes. An established connection alone is not execution acknowledgement.
Generic executed commands do not verify effects: opaque/risky operations
retain unknown change state, verified=false, and unsupported verification.
| Command transport observation | Result certainty |
|---|---|
| No exec request attempted (session/PTY/preflight failure) | completion=not_started, executed=false |
| Exec request attempted but acknowledgement lost | completion=unknown, executed=null, change_state=unknown |
| Positive start acknowledgement | Execution established; completion still depends on exit evidence |
| Exit status observed | Command completion established independently of start-ack reporting |
A timeout, cancellation or disconnect after an attempted exec request must not be downgraded to “not started” merely because the start acknowledgement was lost.
| Observation | Next action |
|---|---|
config, plan_mismatch, plan_unresolved, or blocked admission | Correct/review inputs; do not loosen trust just to make a hash pass |
precondition before commit | Re-read/review the remote state; preserve the intended precondition |
verification_failed, partial/unknown completion, or missing acknowledgement | Inspect state and backup evidence before considering a retry |
timeout / cancelled after admission | Treat remote effects as potentially partial/unknown; transport retryability alone is insufficient |
| Audit persistence failure after successful execution | Recover audit delivery separately; do not repeat the mutation to create a log |
Mutation guarantees are backend-specific
Apply: a verified no-op can be unchanged without a write or backup.
After replacement, readback/hash evidence matters; preserve before/after/payload
hashes and any known backup on failures. Atomic replacement depends on the
server’s rename primitive. There is no delete-then-create fallback guarantee.
Rechecking a hash near replacement does not provide general compare-and-swap
against arbitrary concurrent SFTP writers. --force retains its existing
hash-precondition bypass; it does not bypass --expect-plan.
SFTP/transfer: inspect the returned operation/effect metadata rather than treating a successful copy as proof of content equality. Size-only verification is not a digest comparison. A staged single-file replacement does not imply directory-wide atomicity or rollback. Interrupted recursive transfers may have published some files; relay reads the source and writes only the destination.
SQL: execution acknowledgement, commit acknowledgement, row counts, backup
state and verified effects are distinct. PostgreSQL matched/processed counts,
SQLite changes() and MySQL affected-row counts have different meanings.
Positive counts do not universally prove an UPDATE changed values; zero counts
are not a universal no-side-effects proof. Unsupported verification must remain
explicit. A lost commit acknowledgement is not safe to retry.
SQL’s nested evidence distinguishes:
| Field | Meaning |
|---|---|
affected_rows_semantics | postgres_command_tag, sqlite_changes, or mysql_row_count; legacy affected_rows retains the engine count |
state_change | All mutations currently remain unknown, including zero counts; reads are unchanged |
commit | not_started, unknown, or acknowledged |
verification, verification_method | Nonce-bound client-protocol validation, not a postimage check |
effect_verification | Distinct effect verification; generic mutation postconditions remain unsupported |
backup_status, backup_consistency, backup_format | Whether a planned backup was acknowledged, its locked-preimage consistency and actual format |
outcome_uncertain | Missing commit acknowledgement for a mutation |
evidence.verification=protocol_verified must not be read as
verified=true for value changes. PostgreSQL/SQLite UPDATE counts can describe
processed rows even when assigned values are identical.
Malformed or missing required evidence reports protocol_error or
verification_failed; neither is proof that an admitted mutation rolled back.
PostgreSQL, including Docker execution, streams its CSV preimage in one locked
transaction. Volatile/parenthesized predicates escalate row backups to a
whole-table snapshot; multiline table snapshots remain supported.
SQLite table CSV captures the full table. SQLite’s mutation client holds
BEGIN IMMEDIATE; whole-file .backup runs through a second read-only
client while that writer lock is held. Mutation is sent only after successful
snapshot completion. Running .backup on the same active write-transaction
connection can fail with database is locked; that is not the strategy.
Guarded MySQL backups support simple single-table UPDATE/DELETE on InnoDB, with
an explicit write lock and one mutation session (innodb_table_locks=1,
autocommit=0, LOCK TABLES ... WRITE). The table must be plain and unqualified:
aliases, joins, subqueries, RETURNING and guarded-backup DDL are unsupported.
InnoDB/related-effect checks run after acquiring the lock; triggers/cascades and
unsupported table engines are rejected. The data preimage is streamed and persisted before mutation;
no server-side backup table or backup DDL is created. Format
SSHX_MYSQL_HEX_ROWS_V1 uses hex column/type headers and NULL-aware binary-safe
row values, stored as .mysql-hex with evidence.backup_format=mysql_hex_rows_v1.
It is not CSV, a schema/database dump, or an automatic restore.
Do not generalize supported strategies into universal MySQL atomicity: separate unlocked snapshot/mutation sessions and implicit-commit DDL are not atomic. Real-engine concurrent-writer/rollback evidence is required for the advertised strategy. Unsupported forms require rejection or an explicit independent-backup bypass with reduced guarantees. See the acceptance matrix for which real-engine tests have actually run.
Deadlines and admission stops
--timeout/SSH_TIMEOUTretains command/session semantics and existing defaults: run defaults to 60s, compatibility command mode remains unset. Dial, handshake and password fallback share one connection budget; fallback does not restart that budget. Command timeout remains command-only.- Optional
--host-timeoutcovers an admitted target, including setup and verification. Optional--global-timeoutcovers the operation including queue time. Unset new limits do not silently impose new per-host limits; run retains its existing derived aggregate budget when no global override is supplied. --fail-fastaliases--failure-mode=fail_fast.--max-failures=Nstops admission after the threshold; conflicting policies are configuration errors. Already admitted targets finish and can add further failures. This is not a bound on the final number of failures: up toconcurrency-1other admitted targets can fail after the stopping threshold is recorded.- Cancellation and deadlines independently cancel active transport work.
cancelledis distinct fromtimeout. Closing a local SSH connection or terminating an MCP child does not guarantee remote termination or rollback. Native secret APIs may be noninterruptible; they are checked around the call, not made cancellable by launching unbounded background goroutines.
MCP remains a stdio, one-shot-child adapter. Its process watchdog and progress delivery limits are adapter limits, not remote execution guarantees. Missing or truncated final JSONL must not be interpreted as completed fan-out. Fan-out records admission/failure thresholds before publishing events. The first event-writer failure stops further publishing and returns a local I/O failure while retaining collected target outcomes internally; a broken output sink does not undo or redefine those remote outcomes. Missing terminal events on that sink are not evidence that their targets never ran.
Platform and evidence limits
Local paths follow the client OS; remote SFTP paths use slash separators. POSIX mode/owner/signal guarantees do not imply Windows ACL or process semantics. Native Windows tests, real SQL engines/clients, and an isolated native OS keyring are separate prerequisites. A cross-build, a mocked SQL client, and the test-only secret backend do not prove those boundaries. Audit is best-effort, redacted local evidence; query diagnostics distinguish corrupt records from empty results. None of this adds a daemon, workflow engine, scheduler, or resident remote component.
The native SQL CI lane supplies PostgreSQL 16 / MySQL 8.4. Compiled-CLI
TestSQLRealEngineReliability is opt-in with SSHX_E2E_REAL_SQL=1 and explicit
disposable loopback database credentials; missing clients/services fail an
enabled run. internal/sqlsafe/real_engine_integration_test.go separately tests
rollback/commit and concurrent writers. Both exact tests passed for actual
PostgreSQL 17.11 and MySQL 8.4.11 in isolated, network-disabled Docker fixtures,
using real clients via session-owned docker exec adapters. This is generated
SQL/real-client/real-server component integration, not a mock and not a
compiled-CLI SSH E2E result. The compiled-CLI native SQL opt-in initially failed
for missing host psql/mysql and still awaits its own CI/client prerequisites.
The Windows-selected portable/CLI subset passed on macOS, but native Windows
still requires CI; a local cross-build/platform lint does not substitute.
See native SQL prerequisites.
Usage Scenarios
This page is intentionally example-heavy. Treat the host names as placeholders and adapt the commands to your own runbooks.
Scenario 1: First Health Check
You just received access to a server and want a low-risk check.
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
sshx -h=prod-web -u=deploy "hostname && uptime && whoami"
Why this is useful: it verifies host trust, authentication, the remote user, and basic reachability without changing the server.
Scenario 2: Add A Production Host Once
sshx --host-add \
--host-name=prod-web \
-h=192.168.1.100 \
-u=deploy \
-i=~/.ssh/prod-web.pem \
-pk=prod-web-sudo \
--host-desc="Production web node"
sshx --host-test=prod-web
sshx -h=prod-web "hostname"
Why this is useful: future commands no longer repeat IP, user, key path, and sudo key.
Scenario 3: Check A Service Without Changing It
sshx -h=prod-web "systemctl is-active nginx"
sshx -h=prod-web "systemctl status nginx --no-pager"
For automation:
sshx -h=prod-web --json "systemctl is-active nginx"
Scenario 4: Restart A Service With Review
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl restart nginx"
sshx -h=prod-web "systemctl is-active nginx"
Why this is useful: the dry-run confirms local interpretation before a privileged change.
Scenario 5: Check Disk Pressure On Several Servers
for host in prod-web prod-api prod-db; do
echo "== $host =="
sshx -h="$host" --timeout=15s "df -h / /var /data"
done
Agent-friendly version:
for host in prod-web prod-api prod-db; do
sshx -h="$host" --timeout=15s --json "df -h / /var /data"
done
Scenario 6: Collect Logs For An Incident
sshx text -h=prod-web --path=/var/log/nginx/error.log --preset=exception --json
sshx text -h=prod-web --journal=nginx.service --since='30 min ago' --preset=error --json
mkdir -p incident-2026-07-01/prod-web
sshx -h=prod-web --download=/var/log/nginx/error.log --to=incident-2026-07-01/prod-web/error.log
Why this is useful: Agents dissect exceptions in-place first. Whole-file download is for archives, not live triage.
Scenario 7: Upload A Config Safely
sshx -h=prod-web --upload=./nginx.conf --to=/tmp/nginx.conf
sshx -h=prod-web "sudo nginx -t -c /tmp/nginx.conf"
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
sshx -h=prod-web "sudo nginx -t"
sshx -h=prod-web "sudo systemctl reload nginx"
Why this is useful: the file is staged and validated before replacing the production config.
Scenario 8: Use Different Sudo Keys Per Host
sshx --password-set=prod-web-sudo
sshx --password-set=prod-db-sudo
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl reload nginx"
sshx -h=prod-db -pk=prod-db-sudo "sudo systemctl status postgresql"
Why this is useful: one operator can manage several servers without reusing one global sudo key.
Scenario 9: Validate Every Configured Host
sshx --host-test-all
Run this after rotating keys, changing VPN access, or importing a new settings.json.
Scenario 10: Script A Safe Status Report
for host in prod-web prod-api prod-db; do
sshx -h="$host" --timeout=20s --json "hostname && uptime" \
| jq --arg host "$host" '{alias: $host, success, exit_code, error_kind, stdout}'
done
Why this is useful: scripts read JSON fields instead of scraping terminal prose.
Scenario 11: Bound A Risky Long-Running Command
sshx -h=prod-web --timeout=2m "sudo apt-get update"
Why this is useful: unattended commands should not hang forever.
Scenario 12: Diagnose A Host-Key Failure
If a host key changed, do not bypass it first. Check why it changed.
ssh-keygen -F prod-web
ssh-keyscan -H prod-web
Only update known_hosts after confirming the machine was rebuilt, reinstalled, or intentionally rotated.
Scenario 13: Avoid Shell-Pipe Installers
This command pattern is intentionally high risk:
sshx -h=prod-web "curl -fsSL https://example.invalid/install.sh | sh"
Safer pattern:
sshx -h=prod-web "curl -fsSL https://example.invalid/install.sh -o /tmp/install.sh"
sshx -h=prod-web "less /tmp/install.sh"
sshx -h=prod-web "sha256sum /tmp/install.sh"
sshx -h=prod-web "sh /tmp/install.sh"
Scenario 14: Use PTY Only When Needed
sshx -h=prod-web --pty "sudo visudo -c"
Prefer non-PTY for scripts because it preserves stdout and stderr separation.
Scenario 15: Disable Audit For A Single Sensitive Run
If command text itself would reveal sensitive context, disable audit for that invocation and record the reason in your own runbook.
SSHX_NO_AUDIT=true sshx -h=prod-web "echo redacted"
Do not use this as a default. Audit events are useful for explaining what happened.
Scenario 16: Check Docker Without Opening A Shell
sshx -h=prod-web --json "docker ps --format '{{json .}}' | head -20"
sshx -h=prod-web "docker inspect nginx --format '{{.State.Status}} {{.RestartCount}}'"
Why this is useful: operators can collect container state without starting an interactive SSH session or copying broad logs.
Scenario 17: Verify A Deployment Artifact Before Releasing
sshx -h=prod-web --upload=./dist/app.tar.gz --to=/tmp/app.tar.gz
sshx -h=prod-web "sha256sum /tmp/app.tar.gz"
sshx -h=prod-web "tar -tzf /tmp/app.tar.gz | head"
Only install the artifact after the checksum and archive contents match the release note.
Scenario 18: Rotate A Service Config With Rollback
sshx -h=prod-web --upload=./service.env --to=/tmp/service.env.new
sshx -h=prod-web "sudo cp /etc/myapp/service.env /etc/myapp/service.env.bak.\$(date +%Y%m%d%H%M%S)"
sshx -h=prod-web "sudo install -m 0600 /tmp/service.env.new /etc/myapp/service.env"
sshx -h=prod-web "sudo systemctl restart myapp"
sshx -h=prod-web --json "systemctl is-active myapp"
Why this is useful: the backup, install mode, restart, and health check are separate visible steps.
Scenario 19: Collect A Minimal Support Bundle
mkdir -p support/prod-web
sshx -h=prod-web --download=/etc/os-release --to=support/prod-web/os-release
sshx -h=prod-web --audit-output=support/audit "uname -a"
sshx -h=prod-web --audit-output=support/audit "df -h"
sshx -h=prod-web --audit-output=support/audit "free -m"
Do not download private application data unless the support case explicitly needs it.
Scenario 20: Use -- When Remote Flags Look Like Local Flags
sshx -h=prod-web -- docker run --rm alpine:3.20 sh -c 'echo hello'
sshx -h=prod-web -- echo --force belongs-to-the-remote-command
Why this is useful: -- makes the boundary between local sshx flags and remote command arguments obvious.
Scenario 21: Test A New Host Entry Before Sharing It
sshx --host-add --host-name=staging-api -h=10.0.8.21 -u=deploy -i=~/.ssh/staging.pem -pk=staging-api-sudo
sshx --host-test=staging-api
sshx -h=staging-api --dry-run --json "sudo systemctl reload api"
Only commit or share a runbook after the named host resolves, authenticates, and selects the expected sudo key.
Scenario 22: Keep A Migration Run Bounded
sshx -h=prod-db --timeout=10s --json "pg_isready"
sshx -h=prod-db --timeout=5m --dry-run --json "sudo systemctl restart postgresql"
sshx -h=prod-db --timeout=5m -pk=prod-db-sudo "sudo systemctl restart postgresql"
sshx -h=prod-db --timeout=30s --json "pg_isready"
Why this is useful: every step has a time budget and a machine-readable result.
Scenario 23: Remove A Temporary File With Evidence
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/app.tar.gz
sshx -h=prod-web --list=/tmp
Deletion should be visible before and after. For high-risk paths, prefer a remote mv into a dated quarantine directory before permanent removal.
Scenario 24: Fail Closed In CI
result="$(sshx -h=prod-web --timeout=20s --json "systemctl is-active nginx")"
printf '%s\n' "$result" | jq .
printf '%s\n' "$result" | jq -e '.success == true and .stdout == "active\n"'
Why this is useful: CI fails when the structured result is missing, the command fails, or the service state is not exactly what the runbook expects.
Security Guidelines
Remote execution is high impact. These rules are strict because a small mistake can change production systems, leak credentials, or hide the real cause of an incident.
Non-Negotiable Rules
- Keep host-key verification strict.
- Store passwords in the OS keyring or an explicit encrypted local vault, not in plaintext files, shell history, tickets, or chat.
- Send sudo passwords through stdin only; never place them in command strings.
- Treat
--force,--no-safety-check, and--insecure-hostkeyas exceptional break-glass choices. - Use
--dry-runbefore privileged or destructive operations. - Use
--jsonand explicit exit-code checks for automation. - Remember that command safety checks are not a sandbox.
Production Policy
For production, shared runbooks, CI jobs, and agent-driven operations, treat these as policy instead of suggestions:
- Use named hosts so reviewers can see the intended target.
- Set
--timeouton every unattended command. - Use
--audit-outputfor project, migration, release, and incident work. - Require
--dry-run --jsonbefore a privileged mutation. - Bind reviewed, bindable inputs with
--expect-plan; keep strict public identity/trust instead of relaxing it to make a preview bindable. - Inspect
change_state, nullableexecuted, verification and backup evidence before retrying a timeout/cancellation or failed mutation. Neither a stopped local connection nor a successful process exit proves remote state. - Keep
--forceand--no-safety-checkout of reusable scripts. - Keep
--insecure-hostkeyout of reusable scripts and CI. - Do not run commands copied from chat, tickets, or web pages until they are reviewed against the target host and rollback plan.
- Prefer staged file writes: upload to
/tmp, validate, then install with explicit mode and ownership. - Prefer one visible step per irreversible action; avoid chaining many privileged changes with
&&. - Record the maintenance window, operator, command, result, and rollback decision in your own runbook when the action affects production.
Never make a weak security flag global through shell profiles, CI variables, or shared .env files. A break-glass override must be local to one command and easy to remove.
Risk is descriptive, not authorization: unknown command/script effects default
to mutation even with --intent=read. Ordinary dry-run remains secret-free and
write-free. Plan hashes are not remote locks, and fingerprints are not audit
signatures. See Plans, Outcomes, and Safe Retries.
Host-Key Trust
Default behavior protects against unknown or changed host keys. Use one of these safe paths:
# Recommended: explicitly add the host key after reviewing the target
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
# Accept first use for a known controlled host
sshx --accept-unknown-host -h=prod-web "uptime"
Avoid:
sshx --insecure-hostkey -h=prod-web "uptime"
Use insecure host-key mode only in short-lived controlled labs where the risk is understood and recorded. Never make it the default in scripts or shared runbooks.
Secret Handling
Use interactive keyring storage:
sshx --password-set=prod-web-sudo
Avoid inline secrets:
sshx --password-set=prod-web-sudo:plain-text-password
Inline values can leak through shell history, terminal scrollback, process listings, logs, or copied commands.
Keyring password keys are for sudo auto-fill. SSH_PASSWORD is an SSH login password and should be treated as a high-risk fallback, not a normal operating mode.
On headless hosts without a usable keyring, opt in explicitly:
export SSHX_SECRET_BACKEND=local-vault
export SSHX_VAULT_PASSPHRASE='…'
sshx --password-set=prod-web-sudo
The local vault never displays secret values (--password-get is refused). sshx injects them over stdin. Do not silently copy secrets into a JSON file.
Sudo Rules
sshx auto-fills sudo only when the remote command starts with sudo:
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl reload nginx"
These commands do not trigger sudo auto-fill:
sshx -h=prod-web "sh -c 'sudo whoami'"
sshx -h=prod-web "echo sudo"
This boundary keeps password lookup, stdin injection, and audit metadata aligned to one clear rule.
Safety Checks Are Guardrails
sshx blocks common destructive operations such as root deletion, disk formatting, shutdown or reboot commands, critical system file edits, fork bombs, and curl | sh style pipelines.
Matching applies to the command actually being executed, not to any occurrence of a dangerous word. The command line is split into shell segments and only the token in command position is judged, so read-only diagnostics stay allowed:
sshx -h=prod-web "last reboot -F | head -10" # allowed: reboot is an argument
sshx -h=prod-web "journalctl -u app | grep -iE 'fail|halt'" # allowed: halt is a grep pattern
sshx -h=prod-web "sudo iptables-save | grep -F 10.0.0.0/24" # allowed: a different binary
sshx -h=prod-web "sudo iptables -F" # blocked: flushes the ruleset
Wrappers are followed: sudo, env, nohup, timeout, sh -c '...', and docker exec <container> ... are all resolved to the command they ultimately run.
That does not make untrusted commands safe. A command validator cannot understand every script, shell expansion, application-specific migration, or data-destruction path.
Before bypassing checks:
sshx -h=prod-web --dry-run --json "sudo systemctl reboot"
sshx -h=prod-web --force "sudo systemctl reboot"
Ask:
- Is the target host correct?
- Is the command reviewed?
- Is there a maintenance window?
- Is rollback possible?
- Is the bypass reason recorded?
If any answer is “no”, stop and fix the runbook first. --force should mean “I reviewed this exact command for this exact target”, not “make the tool stop complaining”.
Agent And Automation Rules
Automation should be more conservative than a human terminal:
- Always set
--timeout. - Prefer
--json. - Parse
success,exit_code, anderror_kind. - Run
--dry-run --jsonbefore privileged changes. - Do not set
SSH_INSECURE_HOST_KEY=1globally. - Do not pass plaintext passwords through environment variables unless there is no safer path and the lifetime is tightly controlled.
- Store audit events with
--audit-outputwhen a run belongs to a project, migration, or incident.
Audit Trail Boundaries
Audit events are local JSONL records for provenance. They record metadata such as mode, action, host resolution, sudo/keyring decisions, safety status, authentication method, exit code, error kind, and duration.
They intentionally do not record:
- Plaintext passwords.
- Private key contents.
- stdout.
- stderr.
Command text is included for provenance and redacted for common password or token-style arguments, but do not treat redaction as a reason to place secrets in commands.
Inspection Plugin Trust And Cache Safety
Custom inspection plugins are executable code owned by the local sshx runtime,
normally ~/.sshx/plugins/. Agent skills may explain how to use them but must
not embed or maintain their collector scripts.
- New and edited plugins are untrusted. Review them, run
plugin validateandplugin test, then useplugin trustto admit the exact current digest. - Digest trust is not a sandbox. A trusted collector runs with the selected remote user or sudo identity and should be treated like any reviewed script.
- Plugin code and sshx credentials are never persisted on the remote host. sshx streams the collector through the one-shot SSH session.
--cache=remote-preferis opt-in and stores only normalized, redacted JSON under the authenticated user’s~/.sshx/observations/v1/.- Cached observations are untrusted input. sshx rejects malformed, oversized, symlinked, broadly accessible, wrong-owner, identity-mismatched, and stale entries unless stale reuse was explicitly requested within the hard limit.
- Do not place environment dumps, raw Compose/Nginx configuration, registry authentication, cookies, tokens, private keys, or secret values in facts or evidence. Redaction is defense in depth, not permission to collect secrets.
SFTP Safety
For uploads to privileged paths, stage the file first:
sshx -h=prod-web --upload=./service.conf --to=/tmp/service.conf
sshx -h=prod-web "sudo install -m 0644 /tmp/service.conf /etc/service/service.conf"
For removals, list before deleting:
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/old-file
Remote SFTP paths are remote paths. Do not rely on local OS path rules for remote targets.
Incident Response Checklist
When something looks wrong:
- Stop retrying with weaker security flags.
- Capture the exact command, exit code, and
error_kind. - Check audit events under
~/.sshx/auditor the configured--audit-output. - Verify host-key state with
ssh-keygen -F <host>. - Check whether the failure happened before SSH, during auth, during safety validation, during command execution, or during output collection.
- Rotate exposed credentials if a secret may have entered shell history, CI logs, issue text, or chat.
Good Defaults For Shared Runbooks
sshx -h=<named-host> \
--timeout=30s \
--audit-output=./.sshx-audit \
--dry-run \
--json \
"sudo systemctl reload <service>"
Then run the real command only after the plan is reviewed:
sshx -h=<named-host> \
--timeout=30s \
--audit-output=./.sshx-audit \
-pk=<sudo-key> \
"sudo systemctl reload <service>"
Troubleshooting
Use the failure boundary first: did sshx fail before the remote command ran, or did the remote command run and exit non-zero?
Get Structured Error Details
sshx -h=prod-web --json "systemctl is-active nginx"
Look at:
successexit_codeerror_kindstderrauth_method
An sshx-level failure in JSON mode has exit_code: -1 and a non-empty error_kind.
Host Key Errors
Symptoms:
- Unknown host key.
- Changed host key.
- Connection aborts before authentication.
Checks:
ssh-keygen -F prod-web
ssh-keyscan -H prod-web
Fix only after confirming the host is expected. Do not jump straight to --insecure-hostkey.
Authentication Errors
Check the resolved host and selected key:
sshx -h=prod-web --dry-run --json "whoami"
Common causes:
- Wrong user in
~/.sshx/settings.json. - Wrong per-host key path.
- Key file has bad permissions.
- Server does not allow the selected authentication method.
- You expected keyring sudo password to act as an SSH login password.
Keyring passwords are for sudo auto-fill. They are not silently used as SSH login passwords.
Sudo Does Not Auto-Fill
sshx only auto-fills sudo when the command starts with sudo.
Works:
sshx -h=prod-web -pk=prod-web-sudo "sudo whoami"
Does not trigger auto-fill:
sshx -h=prod-web "sh -c 'sudo whoami'"
Check that the password key exists:
sshx --password-check=prod-web-sudo
On a headless host, Secret Service is often missing. sshx will not silently write a file. Opt in:
export SSHX_SECRET_BACKEND=local-vault
export SSHX_VAULT_PASSPHRASE='…'
sshx --password-set=prod-web-sudo
sshx --password-check=prod-web-sudo
--password-get is refused for the local vault. Dry-run JSON should show
secret_backend: "local-vault".
A Command Is Blocked
Blocked commands are usually safety-check failures.
sshx -h=prod-web --dry-run --json "sudo rm -rf /"
If a privileged or destructive command is genuinely intended, review it, record the reason, and use --force only for that invocation.
Login Types Extra Characters
Symptoms: after sshx login, each keystroke appears more than once. Extra
copies are often dim, gray, or pink. Tab completion or zsh-autosuggestions
look garbled.
Update sshx first. Older builds sent a sparse PTY mode list (echo only), which can make remote zsh reprint typed characters.
If it still happens in the Cursor or VS Code integrated terminal, that
emulator’s local echo (type-ahead) draws predicted keystrokes in a dim
color. It turns itself off for vim/tmux, but not for sshx, and it
fights zsh-autosuggestions:
{
"terminal.integrated.localEchoExcludePrograms": [
"vim",
"vi",
"nano",
"tmux",
"ssh",
"sshx"
]
}
To disable the feature entirely: "terminal.integrated.localEchoEnabled": false.
Compare with ssh in the same terminal. If OpenSSH glitches too, this is
the emulator, not sshx.
Script Hangs
Set a timeout:
sshx -h=prod-web --timeout=30s --json "long-running-command"
If the command requires terminal behavior, use --pty, but remember that PTY mode is less suitable for structured automation.
JSON Output Is Not Parseable
In normal JSON mode, stdout should contain one JSON object and diagnostics should stay on stderr. Check for these issues:
- The command was run with
--pty. - A wrapper script printed extra text around the
sshxcall. - The caller mixed stdout and stderr.
SFTP Path Problems
Use local path rules only for local files. Use slash-separated remote paths for remote targets:
sshx -h=prod-web --upload=./file.txt --to=/tmp/file.txt
Audit Events Are Missing
Check whether audit was disabled:
env | grep SSHX_NO_AUDIT
Check the output location:
ls ~/.sshx/audit
If using a project-local location:
sshx -h=prod-web --audit-output=./.sshx-audit "uptime"
ls ./.sshx-audit
Command Not Found
Check installation:
command -v sshx
sshx --version
If installed with Go, confirm ~/go/bin or your GOPATH/bin is in PATH.
macOS Keychain Prompts During Development
Symptoms (contributors building sshx from source on macOS):
- Every rebuilt binary triggers a Keychain authorization dialog when it reads a stored password.
- Real-keyring E2E runs interrupt with GUI prompts.
Cause: Keychain item ACLs are bound to the binary’s code signature. Each rebuild produces a new ad-hoc signature, so previously granted access no longer matches. macOS has no global per-app allowlist; the two supported mechanisms are a stable signing identity or an ephemeral test keychain.
Fix 1 — ephemeral test keychain for E2E runs (recommended for tests):
make test-keychain-macos
This mirrors CI: it creates a throwaway keychain, makes it the user default,
sets the key partition list so command-line tools need no GUI approval, runs
the E2E suite with SSHX_E2E_REAL_KEYRING=1, and always restores your
original keychain configuration afterwards.
Fix 2 — stable self-signed identity for day-to-day manual use:
-
Open Keychain Access → Certificate Assistant → Create a Certificate. Name it
sshx-dev, set Certificate Type toCode Signing. -
Sign every dev build with it:
codesign -f -s sshx-dev ./bin/sshx -
On the next Keychain prompt choose “Always Allow”. Because the signing identity now stays constant across rebuilds, the approval persists.
Note: routine unit tests never touch the real Keychain — the sshx_e2e build
tag swaps in a file-backed isolated keyring, and the E2E harness only uses the
OS keyring when SSHX_E2E_REAL_KEYRING=1 is set.
SSHX 文档
SSH 是通道,X 代表执行。
sshx 是一个面向 Agent 的远程主机执行工具。它通过 SSH/SFTP 连接现有主机,把目标解析、执行预览、安全检查、命令与文件动作、结构化结果和审计留痕收敛到一次 CLI 调用中。
它保持一个简单的模型:一次命令建立一次连接,完成一个明确动作,返回可判断的结果,写入本地审计事件,然后退出;无需在远端安装常驻 Agent,也不引入长期控制面。
文档默认首页是英文。可以使用顶部导航栏里的语言切换入口打开对应中文页面。
SSHX 擅长什么
- 用稳定的 stdout、stderr 和退出码执行远程命令。
- 把 sudo 密码保存到操作系统密钥链,而不是明文文件。
- 用
~/.sshx/settings.json里的主机短名称代替重复输入 IP、端口、用户和 key 路径。 - 不打开交互式 SFTP 客户端,也能完成常见文件上传、下载和目录操作。
- 用哈希前置条件、备份和原子替换安全地改一个远程文件。
- 输出适合脚本和 AI agent 判断分支的 JSON。
- 用
--dry-run在连接、读取 secret、修改known_hosts或写配置前预览本地执行计划。 - 写入本地 JSONL 审计日志,同时不记录明文密码、私钥、stdout 或 stderr。
- 一次调用探测系统/网络状态,并在 sshx 运行目录创建可复用的应用插件。
- 用
sshx login(可选--sudo)在已配置主机上打开人类交互登录,不必再维护一份~/.ssh/config。
心智模型
把 sshx 理解成 Agent 工具箱里的远程执行基本件,而不是期望状态或工作流编排平台。sshx login 只是给人类用的窄逃生舱,不是 OpenSSH 替代品。
Agent、自动化或人类运维者
|
v
Agent 契约:CLI / JSON / 退出码 / dry-run
|
v
X 执行:目标解析 / 安全检查 / 动作 / 审计
|
v
SSH 通道:认证 / host-key / SSH exec / SFTP
|
v
远程主机
最常用的第一组命令
# 查看参数和示例
sshx --help
# 执行简单命令
sshx -h=192.168.1.100 -u=root "uptime"
# 使用命名主机
sshx -h=prod-web "systemctl is-active nginx"
# 连接前预览执行计划
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
# 给自动化输出机器可读结果
sshx -h=prod-web --json "systemctl is-active nginx"
# 一次采集完整系统/网络基线
sshx inspect -h=prod-web system.baseline --json
安全优先
远程操作工具可能造成真实破坏。sshx 的默认安全路径是严格的:
- 通过
known_hosts校验主机密钥。 - 密码应进入 OS keyring,而不是 shell history 或配置文件。
- sudo 密码通过 stdin 传入,绝不拼进命令字符串。
- 明显危险的破坏性命令默认会被阻止,除非用户显式绕过。
- 安全检查只是防误操作护栏,不是不可信命令的沙箱。
在生产环境或 agent 驱动工作流中使用前,请先阅读安全准则。
下一步
- 项目画像与方向定义产品定位、非目标铁律和验收矩阵。
- 快速开始帮助你让第一台主机跑通。
- 主机管理说明命名主机和密钥选择。
- 使用场景提供大量日常运维例子。
- Agent 与脚本模式说明 JSON、退出码、timeout 和审计日志。
- 计划、结果与安全重试说明审核计划绑定、风险/副作用、 取消以及后端/平台边界。
- 主机探测能力与本地插件说明内置能力、
plugin create、信任和观察快照。 - SFTP 工作流覆盖上传、下载、列目录、创建目录和删除。
- 受控文件 Apply用备份和哈希检查替换一个远程文件。
- MCP 服务器(stdio)说明工具面、进度通知,以及
--pty不在 MCP 范围。 - 审计查询与导出用只读过滤器消费本地 JSONL 审计。
- 契约冻结策略列出 v1 冻结表面和 1.0 门槛。
快速开始
这份指南走一遍安全的首次配置。示例里使用 prod-web 作为主机名,请替换成你自己的服务器名称或 IP。
安装
如果已经安装 Go:
go install github.com/talkincode/sshx/cmd/sshx@latest
sshx --version
sshx skill install
sshx skill install 会把二进制内嵌的官方 Skill 写入
~/.agents/skills/sshx/SKILL.md。使用 Homebrew 安装或升级后也运行同一条命令;
由 sshx 管理的旧版本会自动升级,只有在审阅过本地修改副本后,才使用
--force 覆盖。
也可以不安装,直接运行指定版本:
go run github.com/talkincode/sshx/cmd/sshx@latest --help
先确认 SSH 信任
sshx 默认校验 host key。首次连接前建议先把服务器写入 known_hosts:
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
如果你明确接受首次连接信任,可以使用:
sshx --accept-unknown-host -h=prod-web "uptime"
除短期受控实验环境外,不要使用 --insecure-hostkey。它会关闭防中间人攻击的主机信任校验。
执行第一条命令
sshx -h=prod-web -u=deploy "uptime"
常见变体:
# 非标准 SSH 端口
sshx -h=prod-web -p=2222 -u=deploy "uptime"
# 指定 SSH key
sshx -h=prod-web -u=deploy -i=~/.ssh/prod-web.pem "uptime"
# 给慢命令设置上限
sshx -h=prod-web --timeout=30s "apt-get update"
添加命名主机
命名主机把连接信息集中保存在一个本地文件里:
sshx --host-add --host-name=prod-web -h=192.168.1.100 -u=deploy -i=~/.ssh/prod-web.pem --host-desc="Production web node"
之后可以这样使用:
sshx --host-list
sshx --host-test=prod-web
sshx -h=prod-web "uname -a"
配置文件是 ~/.sshx/settings.json,写入权限为 0600。
打开交互登录
人要登上已经写在 sshx 里的主机时,不必再抄一份到 ~/.ssh/config:
sshx login prod-web
sshx login prod-web --sudo
sshx login -h=prod-web --sudo
sshx login prod-web --dry-run --json
--sudo 会用 keyring 里的 sudo 秘密先提权,再进入特权 login shell。登录需要本地 TTY,目前只支持 POSIX,也不属于 Agent/MCP 契约。
保存 sudo 密码
对于以 sudo 开头的命令,sshx 可以从 OS keyring(或显式本地保险库)读取密码,并通过 stdin 传给 sudo。
sshx --password-set=prod-web-sudo
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl status nginx"
建议使用交互式输入。不要使用 --password-set=key:password 这种内联值,它可能泄露到 shell history 或进程列表。
执行前预览
--dry-run 会说明 sshx 如何解释这条命令,但不会连接、执行、读取 keyring secret、修改 known_hosts 或写主机配置。
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
dry-run 证明本地执行计划,不证明远程命令一定会成功。
主机管理
命名主机把重复的 SSH 信息变成短名称。它适合管理多台服务器、每台服务器使用不同 SSH key、或者 agent 需要稳定主机清单的场景。
添加主机
交互式添加:
sshx --host-add
命令行添加。不要 -pk 就不要写 sudo key;清单不得把运行时回退值 master
当成操作者配置持久化下来。
--json 时 add / update / remove / test / test-all / import 输出
sshx.hosts.v1 文档(success、主机身份;探测失败带 error_kind)。
人类日志走 stderr,不要解析 ✓ 文本。
sshx --host-add \
--host-name=prod-web \
-h=192.168.1.100 \
-p=22 \
-u=deploy \
-i=~/.ssh/prod-web.pem \
-pk=prod-web-sudo \
--host-desc="Production web node" \
--host-type=linux \
--bind=en0
之后用别名执行命令:
sshx -h=prod-web "hostname && uptime"
从 ~/.ssh/config 导入
如果你已经在 OpenSSH 客户端配置里维护主机,可以选择性导入,而不是重新输入。导入默认不会“一键全导“——写入 ~/.sshx/settings.json 的内容由你决定。
交互式选择:
sshx --host-import
sshx 会列出可导入条目(含解析后的 user@host:port 与 key)以及所有被跳过的条目和原因,然后由你按编号、名称或 all 选择。
按名称非交互导入(适合脚本 / agent,全部成功或全部失败)。--json 需要带
名称列表:
sshx --host-import=web1,db1
sshx --host-import=web1,db1 --json
从其他配置文件导入:
sshx --host-import --ssh-config=~/work/ssh_config
预览而不写入:
sshx --host-import=web1 --dry-run --json
每个条目导入的字段:HostName(缺省时使用别名本身)、Port、User、IdentityFile(作为该主机的 key)、BindAddress / BindInterface(作为 bind;先出现的值生效),以及当 ProxyJump 是已有 sshx 主机名时映射为 via。
防污染规则——导入器始终跳过:
- 通配或否定模式(
Host *、web-?、!pattern)——它们是规则,不是主机; - 与 settings 中已有主机同名的条目;
host:port已存在于 settings(或与同文件中更早条目重复)的条目;- sshx 不支持的选项(
ForwardAgent、无法映射的ProxyJump等)——以ignored:显示,不会静默丢失; - 其他块的选项:
Host *的默认值绝不会合并进导入条目; - 含
%令牌的IdentityFile(在提示中说明)。
Match 块会被忽略,Include 指令不会被跟随;被包含的文件请用 --ssh-config=<path> 直接导入。
配置文件
主机定义保存在 ~/.sshx/settings.json。
{
"key": "/Users/alice/.ssh/id_rsa",
"hosts": [
{
"name": "prod-web",
"description": "Production web node",
"host": "192.168.1.100",
"port": "22",
"user": "deploy",
"key": "/Users/alice/.ssh/prod-web.pem",
"password_key": "prod-web-sudo",
"type": "linux",
"bind": "en0"
}
]
}
顶层 key 是默认 SSH 私钥。单个 host 的 key 只覆盖这一台主机。
跳板主机
本机到 app 不可达、但命名主机 edge 可达时,在目标上配置 via。sshx 只为这一次调用先连 edge,再通过 SSH direct-tcpip 通道连 app。不会在本机监听端口,进程退出后没有可拆的隧道。
sshx --host-add --host-name=edge -h=100.64.0.10 -u=jump
sshx --host-add --host-name=app -h=10.0.0.5 -u=app --via=edge
sshx -h=app --json "uptime"
sshx -h=app --via= # 本次强制直连
每一跳独立校验 host key,使用自己的 key 或 ssh_password_key。密钥留在本机。命令行 --via=name 覆盖清单。仍被其他主机当作 via 的主机不能删除。
日常主机命令
# 列出已配置主机
sshx --host-list
# 测试单台主机
sshx --host-test=prod-web
# 测试所有主机,每台使用独立拨号超时
sshx --host-test-all
# 更新主机
sshx --host-update --host-name=prod-web -u=deploy -i=~/.ssh/prod-web-2026.pem
# 修改或清空持久化源地址绑定
sshx --host-update --host-name=prod-web --bind=192.0.2.10
sshx --host-update --host-name=prod-web --bind=
# 删除主机
sshx --host-remove=old-lab
实用命名方式
主机名最好同时说明环境和角色:
prod-web-1
prod-db-primary
staging-api
lab-router
customer-a-jump
password key 不要暴露敏感拓扑。共享 runbook 中尽量使用占位符:
sshx -h=prod-web -pk=<sudo-key> "sudo systemctl reload nginx"
团队和 agent 使用
对人类来说,命名主机减少输入错误。对自动化 agent 来说,它提供稳定边界:
- agent 收到的是
prod-web,不是裸 IP 和 key 路径。 - 操作者可以审阅
~/.sshx/settings.json。 --dry-run --json可以确认真实会使用哪个地址、端口、用户、key 和 sudo key。- 审计事件可以记录解析后的主机,但不保存 secret。
SFTP 工作流
计划与副作用证据
SFTP 与主机间传输支持 --dry-run --json、--expect-plan、
--host-timeout、--global-timeout。dry-run 离线且不写状态;
绑定公开端点/操作与本地上传 payload,不绑定尚未读取的远端内容或递归目录成员。
使用 --json 消费真实 CLI 操作结果、共享执行身份、effects、change_state、
可空 executed 和验证状态。大小/元数据证据不是内容摘要保证。
下载即使远端为 risk=read 也标记本地写入;中转读取源端、写入目的端。
操作结果包含 entries、bytes_transferred、partial 和恒为 false 的
directory_atomic。条目记录路径/类型/大小/mode、复制字节数、开始/发布状态
与验证;内容校验过的文件复制另有 source_sha256 / sha256。
错误以 staging_path / cleanup_error 保留已知暂存/清理证据;
应读取条目实际验证,不能假定列目录等操作也有内容哈希。
上传/中转逐文件暂存后发布;覆盖已有远端目的地需要受支持的原子替换,
不可用时失败而不删除原目的地。新目的地可用普通 SFTP rename 发布。
下载在本地目的地同目录暂存,并使用本地 OS 的 rename 语义;不把 POSIX 保证套用到
Windows。断连后可能无法清理远端暂存,结果会报告。
发布方法为 posix_rename、新文件 sftp_rename_no_replace 和下载
local_rename。文件成功验证包含复制后回读 SHA-256 与元数据;mkdir 验证类型/
存在,remove 验证不存在,list 只提供元数据。目录后续条目失败时,部分结果仍保留
此前已发布条目的证据。
受支持的单文件暂存也不提供目录整体原子性、任意写入者 CAS 或自动回滚。 失败/中断的递归传输可能已经产生部分目的地副作用,重试前应检查状态。 详见计划、结果与安全重试。
sshx 支持常见的一次性 SFTP 操作。它不是交互式文件管理器;每次调用只做一个明确的上传、下载、列目录、创建目录或删除操作。
上传文件
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf
覆盖已有远程文件并需要备份/哈希前置条件时,用 受控文件 Apply,不要自己拼 upload + install:
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json
sshx run --target=prod-web --json -- "sudo nginx -t"
下载文件
sshx -h=prod-web --download=/var/log/nginx/error.log --to=./error.log
事故材料采集示例:
mkdir -p incident-2026-07-01/prod-web
sshx -h=prod-web --download=/var/log/nginx/error.log --to=incident-2026-07-01/prod-web/error.log
sshx -h=prod-web --download=/etc/os-release --to=incident-2026-07-01/prod-web/os-release
列目录与创建目录
sshx -h=prod-web --list=/var/log
sshx -h=prod-web --mkdir=/tmp/sshx-upload
删除远程文件
sshx -h=prod-web --rm=/tmp/old-upload.txt
把远程删除当成生产变更。建议先列出父目录:
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/old-upload.txt
路径边界
本地路径遵循本地操作系统规则。远程路径是 SFTP 路径,应使用斜杠分隔;即使 sshx 在 Windows 上运行也一样。
# 本地 Windows 路径,远程 POSIX 路径
sshx -h=prod-web --upload=C:\Users\alice\release.zip --to=/tmp/release.zip
什么时候改用 SSH 命令
当操作需要远程校验或权限变更时,使用 SSH 命令:
sshx -h=prod-web "sudo ls -l /etc/nginx"
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
SFTP 负责文件移动。远程命令负责检查、改属主、reload 服务和需要 sudo 的清理。
受控文件 Apply
sshx apply 替换一个远程正则文件。它是文件版的 sshx sql:判断目标、检查哈希前置条件、写备份,然后原子替换。服务校验和 reload 不属于这条命令。
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \
--expect-sha256=<current> --sudo --json
Apply 做什么
- 拒绝非干净绝对路径、目录、符号链接和设备节点。
- 默认阻断
/etc/passwd、/etc/shadow、/etc/sudoers,除非显式--force --bypass-reason=。 - 读取现有文件,并在提供
--expect-sha256时做前置校验。 - 除非
--no-backup --force,否则把原文复制到~/.sshx/file-backups/。 - 在同目录写临时文件,保留权限和所有者,再 rename 覆盖目标。
- 返回哈希/备份证据、
change_state、可空executed、verified、verification、条件与completion,同时兼容原changed/created。
如果远程内容已经与 payload 一致,apply 以 changed=false 成功,且不写备份。
验证与并发边界
失败时使用共享 change_state(changed|unchanged|unknown),不能把原布尔值
changed=false 当作无写入证明。no-op 可以未写入而已验证、未变更。
写后 verification_failed 可能表示替换已经发生,但回读失败或摘要不同:
重试前检查 before/after/payload 哈希与已知 backup.path。备份不是自动回滚。
backup.verified 表示备份回读/哈希验证;rollback_available 要求该验证,
而非仅有路径。它仍不是已测试恢复或自动回滚。
自有暂存文件尽力清理;cleanup_pending 列出中断/清理失败后可能残留的路径,
任何删除前应先检查。
| 观测 | 执行/变更证据 |
|---|---|
| 内容已一致 | executed=false、change_state=unchanged、verified=true |
| 发布已确认,必需回读失败 | executed=true、completion=completed、change_state=changed;操作验证失败 |
| rename 确认丢失 | executed=null、completion=unknown、change_state=unknown;先检查再重试 |
尝试发布时,replace_method 标明 posix_rename、sftp_rename 或特权
same_directory_mv;不能推断比报告方法更强的原语保证。
临近替换时复查哈希,但 SFTP 不提供针对任意并发写入者的通用原子
compare-and-swap。原子 rename 依赖服务端原语,SSH 成功不能证明
delete-then-create 安全或文件系统持久化。特权 apply 同样需要有效回读报告,
不能只依靠退出码 0。
POSIX-rename 扩展返回 SSH_FX_OP_UNSUPPORTED 时可尝试普通 rename,
但目标已存在时可能失败;不会以 delete-then-create 绕过该失败。
--force 保留原哈希前置条件绕过、备份/关键路径批准含义,不能绕过 --expect-plan。
特权路径
SFTP 以 SSH 用户身份运行。目标对该用户不可写时使用 --sudo。sshx 先把 payload 暂存到远端 home,再通过 stdin 执行特权安装脚本;脚本不会留在主机上。
写入备份或替换临时文件前,apply 会先检查 SSH 用户是否能写入并访问目标父目录。
原子替换要求父目录同时具备写入和执行权限,与目标文件所有者无关。权限不足时返回
error_kind: parent_directory_not_writable;已配置 sudo 密码 key 时会提示 --sudo。
该预检不能阻止检查后发生的并发权限变更。
校验和 reload 用另一次 sshx run:
sshx run --target=prod-web --json -- "sudo nginx -t"
sshx run --target=prod-web --json -- "sudo systemctl reload nginx"
预览
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json
dry-run 只哈希本地文件并打印本地计划,不连接、不改远程文件。
需严格绑定本地审核与执行时,检查嵌套 sshx.plan.v1 / plan_hash,再以
--expect-plan="$reviewed_hash" 执行。payload 字节与公开身份/信任必须一致;
远端内容由明确前置条件约束,不由计划隐式冻结。
详见计划、结果与安全重试。
什么时候继续用 SFTP
只搬字节、不需要备份合同时用 --upload / --download。需要哈希、备份和明确
变更/验证证据(包括失败后的不确定性)时用 apply。
文本解剖
sshx text 是给 Agent 用的远端文件/systemd journal 解剖面:抽取异常块和错误行。
它不是远端 grep 包装,也不接受 --command。
sshx text --help
sshx text --help --json
sshx text -h=prod-web --path=/var/log/app.log --preset=exception --json
sshx text -h=prod-web --path=/var/log/app.log --around-line=8821 --context=5 --json
sshx text -h=prod-web --journal=nginx.service --since=1h --preset=error --json
工作流
- 先读
sshx text --help(或--help --json)。 - 先拿异常块和计数:
--preset=exception --json。 - 用
--around-line和--context精确切片。 - 只有需要取证归档时才
--download整文件。
源
二选一:
--path=/abs/file— SFTP 流式扫描普通文件。默认--scan=end读末尾 8MiB。拒绝符号链接、目录和二进制。--journal=UNIT— sshx 持有的journalctlargv。Agent 不自由拼 journalctl 参数。
--sudo 经 stdin 注入 sudo 秘密。
结果
JSON schema sshx.text.v1。按 success、hits[].kind、stats.total_hits 与
returned、truncated、redacted、line_origin 分支。默认脱敏。审计不写命中原文。
Agent 与脚本模式
sshx 设计上可以被脚本和 AI agent 调用。契约很简单:稳定的 stdout/stderr、稳定退出码、可选 JSON、可选本地审计事件。
规范执行契约 sshx run
复杂脚本、严格别名选择和有界多主机执行请优先使用:
sshx run --target=prod-web --json -- "systemctl is-active nginx"
sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime"
sshx run --target=prod-web --script-file=./check.sh --dry-run --json
- 选择器只解析已配置主机;字面地址用
--address=,不能进入 group/tag 扩散。 - 脚本经 SSH stdin 原样传输,不经本地
strings.Join拼装。 --sudo会把选定的脚本解释器整体交给 sudo 运行。不要在脚本里再嵌套 sudo 命令:脚本占用了 stdin,sshx 无法再向嵌套 sudo 注入密码。- 脚本的
#!行决定解释器,#!/usr/bin/env bash会真正用 bash 执行(set -o pipefail、数组、[[ ]]都可用)。可用--shell=NAME覆盖。支持sh、bash、zsh、dash、ksh、ash;其他解释器在本地就以error_kind: config拒绝,不建立连接。最终解释器体现在action.script_runner。 - dry-run/结果暴露 payload SHA-256 与字节数,默认不回传脚本全文。
- 多主机
--jsonl输出run_started/target_*/run_finished。 - 多主机退出码:
0全成功,1部分失败/跳过/不确定,255请求级失败。 - 高风险绕过需显式 CLI;
sshx run还要求--bypass-reason=。 - 不再隐式加载工作目录
.env;SSH_FORCE等环境变量不能授权信任降级。
默认输出流
默认不请求 PTY,这样 stdout 和 stderr 会保持分离,也不会把终端控制字符混进脚本输出。
sshx -h=prod-web "systemctl is-active nginx"
当远程命令成功运行后,远程退出码会成为 sshx 进程退出码。
退出码
| 退出码 | 含义 |
|---|---|
0 | 远程命令成功。 |
1..254 | 远程命令以该退出码失败。 |
255 | sshx 层面失败,例如连接、认证、host-key、timeout、命令被阻止、配置或其他本地错误。 |
在 JSON 模式下,sshx 层面的失败使用 exit_code: -1 和非空 error_kind,因此自动化可以把它和远程命令退出 255 区分开。
JSON 输出
sshx -h=prod-web --json "systemctl is-active nginx"
示例结构:
{
"host": "192.168.1.100",
"port": "22",
"user": "deploy",
"command": "systemctl is-active nginx",
"exit_code": 0,
"success": true,
"stdout": "active\n",
"stderr": "",
"duration_ms": 142,
"auth_method": "key"
}
agent 分支示例:
result="$(sshx -h=prod-web --json "systemctl is-active nginx")"
if printf '%s' "$result" | jq -e '.success == true' >/dev/null; then
echo "nginx is active"
else
printf '%s\n' "$result" | jq '{exit_code, error_kind, stderr}'
fi
受控文件 Apply
覆盖一个远程正则文件时优先用 sshx apply。根据 change_state、executed、
verified、verification、completion 和 error_kind 分支。错误时原 changed
不能证明没有变更。提交前 precondition 表示没有目标写入;写后
verification_failed 必须先检查哈希/备份再考虑重试。
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --expect-sha256="$current" --sudo --json
reload 仍是另一次 sshx run。详见 受控文件 Apply。
可复用主机探测
在重复执行一串环境发现命令前,先列出并调用有界探测能力:
sshx plugin list --json
sshx inspect -h=prod-web system.baseline --json
应用级采集器属于 sshx 运行资产,不属于 skill。Agent 可以直接生成完整骨架:
sshx plugin create docker.environment --template=docker --privilege=optional --json
sshx plugin test docker.environment --fixture=complete --json
sshx plugin trust docker.environment --json
sshx inspect -h=prod-web docker.environment --json
应根据观察结果的 status(complete、partial、unsupported、failed)
和 typed errors 分支,不要把权限受限的 partial 解释成服务不存在。新建或
修改后的插件必须先按当前摘要显式信任,sshx 才会建立远端连接。
远端复用必须显式开启:
sshx inspect -h=prod-web docker.environment \
--cache=remote-prefer --max-age=10m --json
远端缓存只保存规范化、已脱敏的观察 JSON;它有主机边界和有效期,不是权威 资产库。完整 manifest、信任、脱敏与失效规则见 主机探测能力与本地插件。
用 dry-run 审核变更
在脚本执行特权操作前,先看计划:
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
用 dry-run 核对主机解析、sudo key、安全检查结果,以及真实执行是否会修改状态。不要把 dry-run 当成远程服务一定能重启成功的证明。
command/run、apply、SQL、SFTP、transfer、inspect 应审核嵌套 sshx.plan.v1
以及外层 plan_hash/risk。当 plan.bindable 为 true 时,以相同输入加
--expect-plan="$reviewed_hash" 执行,在 secret/网络前拒绝本地漂移,但不冻结
远端状态。未知命令/脚本保持 mutation 风险及未知副作用,不由调用方 intent 降级。
身份前提、信任记录失效和结果决策表见计划、结果与安全重试。
超时
无人值守工作流应总是设置 timeout。未指定 --timeout / SSH_TIMEOUT 时,
sshx run 默认命令超时为 60s;兼容 sshx -h=... 命令模式默认仍不设置。
SSH 拨号超时独立(30s)。
sshx -h=prod-web --timeout=30s --json "systemctl is-active nginx"
sshx -h=prod-web --timeout=2m --json "sudo apt-get update"
--host-timeout=2m 限制已准入目标的整个生命周期,
--global-timeout=5m 限制整个操作及排队时间。新期限可选;未覆盖时保留 run
原推导总预算。取消会关闭本地传输,但不保证远端终止/回滚;原生 secret backend
调用可能不可中断。
fan-out 支持 --fail-fast(--failure-mode=fail_fast 别名)和
--max-failures=N。阈值只停止新准入,活跃目标继续完成且可能继续失败。
必须区分未开始与取消/不确定的工作;不能仅凭传输错误重试变更。
审计事件
非 dry-run 调用默认写入本地 JSONL 审计事件:
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
把审计事件保存到项目或事故目录旁边:
sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
审计事件用于溯源。它记录元数据和结果,但不记录明文密码、私钥内容、stdout 或 stderr。
PTY 需要显式启用
某些命令需要终端语义:
sshx -h=prod-web --pty "top -b -n1"
不要把 --pty 和 --json 混用。PTY 会把 stderr 合并进 stdout,让结构化自动化变得不稳定。
主机探测能力与本地插件
Agent 初次接触服务器时,往往需要反复检查 Docker、Compose、路由、DNS、
网卡、防火墙和资源状态,还需要判断“没有结果”究竟是服务不存在,还是权限
不足。sshx inspect 将这些探索收敛为一次有版本、可校验的结构化调用。
内置系统能力
稳定的操作系统能力直接内置在 sshx 中:
system.identitysystem.resourcessystem.baselinenetwork.interfacesnetwork.routesnetwork.dnsnetwork.listenersnetwork.firewall
一次采集完整基线:
sshx inspect -h=prod-web system.baseline --json
结果是 sshx.observation/v1 JSON 文档。status 明确区分 complete、
partial、unsupported 和 failed;权限不足不会被误报成服务不存在。
插件属于 sshx 运行目录
应用级采集器是 sshx 的本地运行资产,不放在 Agent skill 中,也不会安装到 远端服务器:
~/.sshx/
├── settings.json
├── audit/
├── plugins/
│ └── <plugin-id>/
│ ├── manifest.json
│ ├── collectors/
│ ├── result.schema.json
│ ├── README.md
│ └── fixtures/
├── plugin-lock.json
└── observations/
可通过 SSHX_HOME 替换默认的 ~/.sshx,用于项目、Agent 或 CI 隔离;
settings、audit、plugins 和 lock 都跟随同一个运行根目录。
创建自定义插件
plugin create 会生成可直接编辑和验证的完整骨架:
sshx plugin create private.environment \
--runner=sh \
--platform=linux \
--privilege=optional \
--template=generic \
--json
模板包括 generic、docker 和 nginx。Docker 模板采集安装与 daemon
状态、版本、Docker 根目录、存储/cgroup 驱动、容器、镜像、端口、网络、
挂载以及 Compose project/工作目录/配置路径。默认不采集容器环境变量、
registry auth、.env 内容、Secret 值或 Compose 文件正文。
插件 API v1 使用面向 Linux 或 Darwin 目标的 sh runner。sshx 控制端仍保持
跨平台;未来的 Windows 目标 runner 必须先补充明确执行与测试契约,不能把
PowerShell 静默当成 POSIX shell。
--replace 会先把旧插件移动到 ~/.sshx/plugin-backups/;plugin remove
同样采用可恢复移动,而不是直接永久删除。
校验、测试与信任
sshx plugin validate private.environment --json
sshx plugin test private.environment --fixture=complete --json
sshx plugin test private.environment --json
sshx plugin trust private.environment --json
sshx plugin show private.environment --json
sshx plugin list --json
validate 检查 manifest、路径、文件类型与权限、入口、JSON Schema、超时、
权限声明、缓存策略和副作用声明。test 可以校验 fixture,也可以显式在本地
最小环境中执行采集器,并限制 stdout/stderr 大小。
新建或修改后的本地插件默认不可信。plugin trust 将 manifest、入口和 schema
摘要写入 plugin-lock.json;以后任何修改都会改变摘要,inspect 会在建立 SSH
连接前拒绝执行,直到新摘要再次得到显式信任。信任不是沙箱:可信插件仍拥有
SSH 身份允许的权限,因此信任前必须审查内容。
远端临时执行,不安装脚本
sshx inspect -h=prod-web private.environment --json
sshx 在本地解析并校验插件,然后通过固定的 sh -s -- SSH 会话把采集器送入
stdin。stdout 必须是唯一一份符合 schema 的 JSON;sshx 随后脱敏并补充目标、
来源和新鲜度信息。采集器不会持久安装到远端,也不会获得 SSH 或 keyring 密钥。
manifest 的权限策略为:
never:禁止--sudo。optional:默认普通用户,确有需要时显式增加--sudo。required:sshx 解析对应 sudo key,并把密码和采集器内容分离送入 stdin。
执行前可以预览完整边界:
sshx inspect -h=prod-web private.environment \
--cache=remote-prefer \
--dry-run \
--json
预览包含插件路径、摘要、信任状态、目标解析、权限、是否读取 secret、是否执行、 known_hosts 影响以及是否写观察快照,全程不连接远端。
有有效期的远端观察快照
缓存必须显式启用:
sshx inspect -h=prod-web private.environment \
--cache=remote-prefer \
--max-age=10m \
--json
远端只在当前用户的 ~/.sshx/observations/v1/ 保存规范化、已脱敏 JSON,
插件代码仍然只在本地。目录和文件仅属主可访问,并使用原子替换。
只有 capability ID/版本/摘要、schema、参数、host-key 指纹、平台、认证 UID、
boot ID 和权限范围全部一致时,快照才可复用。TTL 到期或 --refresh 会重新采集;
--allow-stale 只是显式允许返回匹配但过期的快照,不会伪装成新鲜数据。
缓存始终按不可信输入处理:路径中的软链接、宽松权限、属主不符、超大文件、 畸形 JSON、schema 不匹配和身份漂移都会失败关闭,而不是被当成当前事实。
这只是观察缓存,不是 CMDB:不提供跨主机搜索、资产归属、期望状态、持续 收敛,也不声称它是权威资产库。
MCP 服务器(stdio)
sshx mcp 把 sshx 执行契约通过 Model Context Protocol 暴露出去,让支持 MCP
的 Agent(Claude Desktop、IDE agent、自定义客户端)把 sshx 当原生工具调用,
而不必自己拼 shell。
sshx mcp
服务器只讲 stdio 上的 MCP。它由 MCP 客户端拉起并拥有,不持有 SSH 连接、 不保存状态,客户端关闭流就退出。每次工具调用都会把 sshx 二进制再以一次性 子进程重入——进程模型、安全门、keyring 访问和审计轨迹与 CLI 完全相同。
客户端配置
Claude Desktop / 通用 MCP 客户端条目:
{
"mcpServers": {
"sshx": {
"command": "sshx",
"args": ["mcp"]
}
}
}
工具
| 工具 | 映射到 | 说明 |
|---|---|---|
sshx_run | sshx run --json | 选择器、命令或按字节保真的脚本(shebang 或 shell 选解释器)、有界扩散、dry-run、force + bypass_reason |
sshx_sql | sshx sql --json | 经远端 psql / sqlite3 / mysql 的受控单语句 SQL |
sshx_apply | sshx apply --json | 受控单文件替换;接受 from_path 或内联 content(允许空字符串) |
sshx_inspect | sshx inspect --json | 内置能力与已信任的本地插件 |
sshx_text | sshx text --json | 有界文件/journal 解剖:异常块、preset、行窗 |
sshx_sftp | SFTP flags | upload / download / list / mkdir / remove |
sshx_transfer | --transfer | 经本机中转的服务器到服务器流式传输 |
sshx_host_list | --host-list --json | 只读 sshx.hosts.v1 清单 |
带行过滤条件的 SQL 若需扩大为整表快照,默认会阻断。只有确实接受更宽的 before-image
时,才在 sshx_sql 中设置 allow_full_table_backup: true;这不允许没有 WHERE 的
UPDATE / DELETE。
工具结果就是 CLI 的版本化 JSON(例如 sshx_run 的 sshx.result.v1),因此
success、error_kind、completion 和重试指引与 CLI 文档完全一致。子进程
非零退出会把 MCP 结果标成 tool error,但保留结构化载荷。
当 MCP 客户端在 sshx_run 上提供 progressToken 时,服务器用 --jsonl
跑子进程,并将 target_finished 事件通过有界、尽力投递的 MCP
notifications/progress 通知发送。进度的 total 是选中的目标数。最终工具文档
仍是 CLI 结果:单目标调用返回 sshx.result.v1;多目标调用仍返回 JSONL 流。
--pty 永久不在 MCP 范围内。PTY 会把 stderr 并进 stdout,且与 --json
不兼容。Agent 应继续使用 sshx_run / sshx_sql / sshx_apply,而不是交互式
终端。
计划绑定、期限与投递
远端工具支持 expect_plan、host_timeout_seconds、global_timeout_seconds,
与 CLI 对应;timeout_seconds 保留命令/会话含义。run 还支持 fail_fast 和
max_failures,只停止新准入,不停止已活跃目标。不透明命令/脚本的 mutation 风险
不能由调用方 intent 降级。
SQL 支持可选 bypass_reason 记录原有策略批准,不给旧 SQL 调用新增必填理由。
run 的可选 request_id 用于调用方关联,不替代唯一 execution_id;
inspect 支持相同的 dry_run 预览流程。
以 dry_run: true 预览、检查 plan.bindable / plan.unresolved,再以相同输入
和已审核 expect_plan 执行。内联 payload 的暂存路径不是身份,字节才是。
公开 IP/key/信任前提、整个信任记录快照的保守失效及远端 SQL 发现限制与 CLI 一致;
共享执行 ID、指纹、变更状态与验证来自 CLI,不另创 MCP schema。
适配器有独立的 30 分钟进程 watchdog;较短的显式全局期限额外保留两分钟报告宽限, 但不超过 watchdog。命令 timeout 不会被乘算成新的 fan-out 限制。 取消先请求子进程协作关闭,再有界升级终止;各平台的本地进程关闭不证明远端终止。 三秒宽限后仅终止本次拥有的进程树(Unix process group 或 Windows job object), 不处理无关进程。
进度投递有界且独立于 stdout 排空,进度通知不是权威完成记录。JSONL 格式错误、 超限/截断、缺少最终完成或输出投递失败是适配器错误,不能合成为成功结果。 重试前应区分执行结果与适配器失败。详见计划、结果与安全重试。 当前适配器限制:stdout/单事件 64 MiB、stderr 1 MiB、保留 100,000 个事件、 64 条进度队列;进度写入/投递宽限为 250 ms,stdio 会话停滞时关闭会话, 不遗留阻塞的 SDK writer。这是投递限制,不是远端输出或副作用验证保证。
安全模型
- 同一套门、同一份证据。 安全检查、host-key 校验、keyring 凭据角色和审计
都在子进程里按 CLI 原样执行。run 的
force/no_safety_check必须带显式bypass_reason;apply 保留关键路径理由要求,SQL 保留原批准参数与可选记录理由。 - 审计归属。 子进程调用的审计事件带
entry: "mcp",以便区分 MCP 来源与 交互式 CLI。该标记只是元数据,不改变信任或安全决策。 - 没有密钥表面。 密码管理(
--password-set等)故意不做成工具。先用 CLI 配好凭据;MCP 工具只引用 keyring key。 - 不能靠省略来放松信任。 接受未知 host key 不是工具参数。请事先显式信任
主机(例如
sshx --host-test或一次有人监督的 CLI 运行)。 - 仅 stdio。 没有 HTTP/SSE 传输、没有监听套接字、没有常驻服务;这条边界 写在 AGENT.md §3。
典型流程
- 用 CLI 配置并信任主机(
--host-add、--host-import、--host-test)。 - 把凭据存进 OS keyring(
--password-set=...)。 - 把 MCP 客户端指向
sshx mcp。 - Agent 用
sshx_host_list发现清单,用dry_run: true预览,执行,并按 结构化结果分支。
审计查询与导出
sshx 每次非 dry-run 调用都会写一条 JSONL 审计事件,默认落在
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl(或 --audit-output=<dir>)。
sshx audit 是只读消费面。它不会轮转、删除、上传或改写审计文件,
也不会为这次查询本身再写一条审计事件。
查询
sshx audit query --json
sshx audit query --since=2026-09-01 --until=2026-09-02 --target=prod-web
sshx audit query --run-id=<id> --error-kind=blocked --bypass-only --json
sshx audit query --execution-id=<id> --json
过滤器是 AND 组合:
| 参数 | 匹配字段 |
|---|---|
--since= / --until= | timestamp,接受 RFC3339 或 YYYY-MM-DD |
--target= | host_input、host_resolved 或 host_name |
--action= | action 或 mode |
--run-id= | run_id 或 event_id |
--execution-id= | execution_id 或 parent_execution_id,独立于原 run ID |
--error-kind= | outcome.error_kind |
--bypass-only | force 或非空 bypass_reason |
空结果退出码为 0。--json 输出 sshx.audit.query.v1,包含 success、
count 和 events(无匹配时为空数组)。
导出
sshx audit export --to=./incident.jsonl --run-id=<id>
sshx audit export --to=./siem.jsonl --since=2026-09-01 --json
把匹配事件以 JSONL 写入 --to=。sshx 不是 SIEM;导出只是一份权限为
owner-only 的有界交接文件。
完整性与持久化
结果与审计复用最终计划/执行身份和脱敏结果证据。execution_fingerprint
不含原始 stdout/stderr 或秘密;它不是数字签名,也不保证日志防篡改。
审计可记录公开凭据角色引用(ssh_password_key、sql_password_key、
sql_user)、观测到的 peer_address / host_key_fingerprint,
以及 cancellation_cause、deadline_scope、stop_reason。
缺少 peer 证据不能证明建立过连接;这些字段不含所引用的密码。
Apply 审计保留观测的 apply_expect_sha256、apply_payload_sha256
(包括空 payload)、前后哈希与备份路径。存在 outcome 时,
apply_backup_verified 是明确布尔值;apply_mode、apply_uid、apply_gid、
apply_cleanup_pending、apply_replace_method 保留属主、清理与发布证据。
SQL 引擎证据嵌套在 sql_evidence;共享取消/期限字段来自同一最终元数据,
不是审计另行推断。
格式错误/部分写入的 JSONL 行会跳过并给出可见诊断,有效记录继续保留。
损坏文件不能等同于干净的空查询;读取/扫描和导出写入失败应报错,而非零匹配成功。
query/export 保留有效记录中的未知字段。
需要时 JSON 含非零 skipped_records 和 warnings,警告标明文件、行号及
error_kind: audit_record_invalid|local_io,不包含损坏记录内容。
仅跳过格式错误、非对象或部分记录时,保留 success=true 和有效记录。
兼容的 flat query 错误仍是 error_kind: config,由 error_details.kind
增量提供 I/O 边界分类。
读/扫描/写入失败时,success=false 仍可携带保留的有效 events;
不应丢弃这些记录,也不能把部分响应当成完整结果。
其他可读文件仍继续处理;导出原样保留有效原始记录中的未知字段和值。
执行审计仍是尽力写入,持久化状态/警告与执行结果分开,不会把成功的变更变成
应重试的失败。应独立修复审计目的地。详见结果决策表。
生命周期 JSON 的 audit_status: written|failed|disabled 不纳入执行指纹。
sshx 契约冻结策略
Agent 生态会惩罚接口漂移。本文是对 v1 schema 冻结范围、以及何时必须升到
v2 的公开承诺。
冻结的 v1 表面
在同一个主 schema 版本内,变更只能增量添加。删除、重命名或改变字段、
flag、退出码、error_kind 的含义,都是破坏性变更。
| 表面 | 冻结名称 | 说明 |
|---|---|---|
| 请求 schema | sshx.request.v1 | sshx run 请求信封 |
| Run 结果 schema | sshx.result.v1 | 单目标 run;兼容命令 JSON 保留原字段 |
| 计划 schema | sshx.plan.v1 | 嵌套在原预览中,不替换 sshx.request.v1 |
| 事件 schema | sshx.event.v1 | JSONL run_started / target_started / target_finished / run_finished |
| 主机清单 | sshx.hosts.v1 | --host-list 以及主机增删改/探测 JSON |
| 密钥 | sshx.secrets.v1 | --password-check / --password-list / --password-set JSON |
| 审计事件 | sshx.audit.v1 | 每次非 dry-run 调用一条 JSONL |
| 审计查询 | sshx.audit.query.v1 | sshx audit query/export --json |
| 文本解剖 | sshx.text.v1 | sshx text --json;帮助文档为 sshx.text.help.v1 |
| 退出码 | 0、1..254、255 | 远端状态 vs sshx 层失败 |
| JSON sshx 失败 | exit_code: -1 | 与远端 exit 255 可区分 |
error_kind | timeout、auth、host_key、connect、blocked、exit_missing、config、error,加上 SQL/apply 扩展 | 按此字段分支,不要解析散文 |
| JSONL 事件类型 | run_started、target_started、target_finished、run_finished |
某次已发布次版本 sshx --help 里列出的 CLI flag,在该主版本线剩余时间内
保持有效。可以新增 flag,但不能给已有调用形状引入新的必填 flag。
兼容策略
- v1 文档随时可以出现新字段。Agent 必须忽略未知字段。
- 破坏性变更需要新 schema(如
sshx.result.v2),并提供 N-1 支持窗口: 新 schema 发布后,直到再下一个 sshx 主版本之前,旧 schema 仍要发出或接受。 --json的 stdout 必须是机器文档。人类日志走 stderr。- MCP 工具原样返回 CLI JSON。MCP 不另起一套 schema。
增量执行加固
计划、结果与安全重试 定义 --expect-plan、
标量风险与副作用、执行身份/指纹、可空执行观测、验证、期限和重试判断。
这些字段不重定义既有 intent、changed、force、completion 或退出状态。
新增 plan_mismatch、plan_unresolved、cancelled、verification_failed
类别遵循增量策略;应解析类别,而非错误散文。
不要假设历史 JSON 都有 schema tag 或相同信封。run、apply、SQL、探测、 兼容命令与文件操作保留各自领域 payload;共享元数据是增量,不是替换结果对象。 JSONL 事件名称保持不变。
1.0 证据门槛
1.0 不是日期,也不是整列打勾;每项门槛必须有证据:
| 门槛 | 证据与剩余边界 |
|---|---|
| CLI/MCP 契约一致 | internal/app/mcp_test.go、tests/e2e/mcp_e2e_test.go;损坏流与关闭需要专门测试 |
| Windows 正确性 | 原生 CI 而非交叉编译;POSIX 权限、login 与 signal 不等于 Windows 保证 |
| Secret backend 生命周期 | internal/keyringstore 加隔离原生钥匙链测试;e2e backend 不证明原生集成 |
| 冻结 JSON/JSONL 与退出码 | 契约 fixture 与编译后二进制测试;历史信封必须仍可消费 |
| 变更/恢复证据 | 真实文件/SQL 状态与备份校验;模拟行数不证明引擎事务 |
具体测试路径与外部前提见验收矩阵。 文件中存在测试不等于所有平台都执行过。
在 1.0 之前,次版本仍可增加一级能力,但不得破坏本文冻结的名称。
计划、结果与安全重试
本文描述 issue #71 的增量执行加固契约。既有参数、intent/force 含义、结果信封、 退出码和 JSONL 事件名称保持兼容。兼容政策见契约冻结策略, 验证证据与外部前提见验收矩阵。
审核并绑定本地计划
非交互 command、run、apply、sql、SFTP、transfer、inspect 预览增加
嵌套 plan(schema_version: "sshx.plan.v1")、plan_hash 和 risk。
run 预览仍保留外层 sshx.request.v1。
sshx run --target=prod-web --script-file=./deploy.sh --dry-run --json
# 审核 plan.bindable、plan.unresolved、risk、effects、targets、inputs。
# 原样传入已审核的 sha256:<64 位小写十六进制>:
sshx run --target=prod-web --script-file=./deploy.sh \
--expect-plan="$reviewed_hash" --json
--expect-plan 是可选参数,在 secret 查询或网络操作前比对本次准备的计划。
它不是审批服务,调用方必须保护已审核哈希。格式错误为 config,哈希不同为
plan_mismatch,哈希相同但不可绑定为 plan_unresolved。--force 不能绕过
计划比对;拒绝时仍可写入正常的脱敏审计事件。
普通 --dry-run 始终离线、不读取 secret、不写状态:不写审计、信任、
缓存或 payload 暂存文件。它只读取公开配置、公钥/信任材料与本地 payload,
不读取私钥,也不向 secret backend 查询秘密值。
哈希绑定什么
哈希是规范化语义 JSON 投影的 sha256:,不是渲染后预览文本的哈希。
目标和 map 键排序;有语义的命令/脚本/SQL 字节保持原样;时长规范为整数纳秒。
schema 与执行语义版本属于投影的一部分。
纳入的输入包括端点角色、地址/用户/端口/源绑定、公有凭据身份与角色引用、
信任快照、payload 摘要/长度、解释器/PTY/权限、路径与写入目的地、
SQL 分类/身份/备份策略、插件摘要/缓存策略、安全绕过、条件以及有效执行/失败限制。
ID、时间、格式、审计目录、原始输出、密码和私钥不属于计划语义。
相同快照字节定义操作时,payload 本地来源路径不是身份;下载目的地则属于身份。
selector_digest 仍只描述选择结果,不能代替完整计划绑定。
**信任失效采取保守策略:**当前实现排序并哈希选定 known_hosts 文件中
全部非空、非注释记录,而非仅目标主机相关记录。因此修改无关信任记录也可能
使旧计划失效。绑定执行使用已准入的信任快照,不重新打开可变文件。
哪些预览不能绑定
- 仅有 DNS 名称的目标:离线预览不解析或冻结 DNS 答案,需使用明确的 IP。
- key 认证缺少可用的
<私钥路径>.pub:预览不会读取私钥来推导公钥。 真正加载的 signer 必须与准入公钥指纹一致。 - 信任材料缺失/不可读、peer 不受信任,或使用放宽信任参数
--accept-unknown-host/--insecure-hostkey。在线 peer-key 拒绝仍为host_key。 - 依赖远端发现/客户端默认值的 SQL 身份:应明确数据库与角色;
容器/
--db-cred-from发现无法离线固定,在绑定模式下拒绝。
纯密码认证绑定明确主体、backend 和引用,不绑定密码字节;秘密轮换或远端角色 策略变化不是本地哈希能冻结的事实。不使用绑定时保留原行为。
脚本/apply 字节与绑定上传使用本地快照执行。远端行、文件内容、递归目录成员、 程序版本、权限与观察缓存内容不会被计划隐式快照。远端前置检查发生在本地 准入之后;计划哈希不是远端锁。
风险不等于意图或授权
risk 是标量:read < mutation < privileged < destructive。
effects 同时保留 unknown、remote_write、local_write、privileged、
destructive,防止一个风险等级遮盖其他副作用。
不透明命令/脚本默认 mutation 且副作用未知;--intent=read 不能证明只读。
sudo 读取仍是 privileged,删除是 destructive,上传/apply/中转会远端写入。
下载可以是远端 read,同时明确标记本地目的地写入。带远端缓存的探测包含缓存写;
自定义采集器不假定为纯读取。既有安全门与绕过要求继续决定准入;分类不是授权或沙箱。
重试前先解释结果
以下共享字段增量加入各动词的原结果信封:
| 字段 | 含义 |
|---|---|
execution_id、parent_execution_id | 调用/目标关联;调用方 request ID 和原 run ID 含义不变 |
plan_hash、risk、effects | 已准入公开计划及预期副作用 |
execution_fingerprint | 最终脱敏执行证据的摘要,不是签名或防重放令牌 |
peers、target_fingerprints | 观测的公开 peer/auth 身份,以及 run 父级的最终目标指纹 |
change_state | changed、unchanged、unknown;未知不能当作 false |
executed | 可空执行观测;null 表示未知,不是“没执行” |
verified、verification | 必需证据是否验证及其状态;进程成功不等于副作用已验证 |
preconditions、postconditions | 可选条件数组,含 kind、subject、expected、observed、status |
指纹不包含原始 stdout/stderr、秘密值或显示格式。它用于对照可信参考关联证据,
不使审计存储防篡改。成功可以未验证副作用;失败也可能已经发生变更。
apply 原有 changed / created 布尔值继续兼容,失败时应读取 change_state。
指纹只覆盖最终共享投影,不能假设所有历史信封字段都纳入;
投影之外单独报告的 peer/auth 字段不会自动绑定。
共享 peers 投影现在绑定实际传输地址、host-key 指纹、认证方式、有效用户与
SSH/sudo 凭据引用。目标指纹包含这些观测事实;父级通过 target_fingerprints
间接绑定。采集发生在连接/认证成功之后,不能承诺此前失败保留观测 peer。
缺失的实际地址/key 不会用计划目标配置补造,凭据引用也不含秘密值。
run 的父执行 ID 同时是 run ID,目标执行 ID 由父 ID 与规范目标身份/索引确定生成。
单目标结果使用目标自身元数据,不是 run 父级元数据。父级 run_finished 元数据
保留排序后的最终目标指纹。调用方 request_id 仅用于关联。
JSONL writer 可写时,seq 从 1 连续递增,每个选中目标恰有一个终态事件;
未准入的 skipped 目标没有 target_started。最终计数满足:
Selected = Succeeded + Failed + Skipped
Started = 已准入 = Succeeded + Failed
Uncertain 是 Failed 的子集,不另加到总数
选择器拒绝的候选不计入 selected。partial、completed_unconfirmed、unknown 属于
不确定完成;建立连接本身不是执行确认。通用命令不验证副作用,不透明/高风险命令
执行后仍为未知变更、verified=false 和不支持副作用验证。
| 命令传输观测 | 结果确定性 |
|---|---|
| 尚未尝试 exec 请求(session/PTY/前置检查失败) | completion=not_started、executed=false |
| 已尝试 exec 请求,但确认丢失 | completion=unknown、executed=null、change_state=unknown |
| 收到明确 start 确认 | 已执行;是否完成仍需退出证据 |
| 观测到退出状态 | 独立于 start 确认报告,可确定命令完成 |
exec 请求尝试后的超时、取消或断连,不能仅因 start 确认丢失就降级成“未开始”。
| 观测 | 下一步 |
|---|---|
config、plan_mismatch、plan_unresolved 或准入阻断 | 修正并重新审核输入,不为通过哈希而放松信任 |
提交前 precondition | 重新读取/审核远端状态,不丢弃原前置条件 |
verification_failed、部分/未知完成或缺少确认 | 先检查状态与备份证据,再考虑重试 |
准入后 timeout / cancelled | 副作用可能部分完成/未知;传输可重试不等于变更可重试 |
| 成功执行后审计写入失败 | 单独恢复审计投递,不重复变更来补日志 |
变更保证依赖后端
Apply:验证过的 no-op 可以 unchanged,无需写入或备份。替换后须看回读/
哈希证据;失败仍保留前后/payload 哈希和已知备份。原子替换依赖服务端 rename
原语,不承诺 delete-then-create 降级。临近替换时复查哈希不等于针对任意
并发 SFTP 写入者的通用 compare-and-swap。--force 保留原哈希前置条件绕过,
不能绕过 --expect-plan。
**SFTP/transfer:**应读取操作/副作用元数据,而非将复制成功当作内容相同的证明。 仅大小验证不是摘要比对。单文件暂存替换不提供目录整体原子性或回滚;中断的递归 传输可能已经发布部分文件。中转读取源端,只写目的端。
**SQL:**执行确认、提交确认、行数、备份状态和副作用验证是不同事实。
PostgreSQL 匹配/处理行数、SQLite changes()、MySQL affected rows 语义不同。
正数不能普遍证明 UPDATE 改变了值,零也不能普遍证明没有副作用。
不支持的验证必须明确;丢失提交确认不能安全盲重试。
SQL 嵌套 evidence 区分:
| 字段 | 含义 |
|---|---|
affected_rows_semantics | postgres_command_tag、sqlite_changes 或 mysql_row_count;原 affected_rows 保留引擎计数 |
state_change | 当前所有变更均保持 unknown,包括零计数;读取为 unchanged |
commit | not_started、unknown 或 acknowledged |
verification、verification_method | nonce 绑定客户端协议校验,不是 postimage 检查 |
effect_verification | 独立的副作用验证;通用变更后置条件仍不支持 |
backup_status、backup_consistency、backup_format | 计划备份是否确认、锁定前镜像的一致性与实际格式 |
outcome_uncertain | 变更缺少提交确认 |
evidence.verification=protocol_verified 不能当作值变化已 verified=true。
PostgreSQL/SQLite UPDATE 计数可能表示处理的行,即使赋值与原值相同。
必需证据畸形或缺失时报告 protocol_error 或 verification_failed;
二者都不能证明已准入的变更回滚了。
PostgreSQL(包括 Docker)在一个锁定事务中流式输出 CSV 前镜像。
volatile/带括号谓词使行备份升级为整表快照;整表快照支持多行 SQL。
SQLite 表 CSV 捕获整表,由变更客户端持有 BEGIN IMMEDIATE;
整文件 .backup 在持有写锁时通过第二个只读客户端执行,快照完成后才发送变更。
在同一活跃写事务连接上 .backup 可能报 database is locked,不是采用的策略。
MySQL 受控备份支持 InnoDB 简单单表 UPDATE/DELETE,使用一个变更会话与显式写锁
(innodb_table_locks=1、autocommit=0、LOCK TABLES ... WRITE)。
表名必须为简单、无 schema 限定的名称;别名、JOIN、子查询、RETURNING 和带受控
备份的 DDL 不支持。获得锁后检查 InnoDB/关联影响,拒绝触发器/级联和不支持的引擎。
数据前镜像流式输出并持久化后才发送变更,不创建服务端备份表或执行备份 DDL。
格式 SSHX_MYSQL_HEX_ROWS_V1 采用十六进制列名/类型头和可区分 NULL 的二进制安全
行值,保存为 .mysql-hex,evidence.backup_format=mysql_hex_rows_v1;
它不是 CSV、schema/整库 dump 或自动恢复脚本。
不能把受支持策略推广为通用 MySQL 原子性:无锁的分连接快照/变更与隐式提交 DDL 并不原子。宣称的策略必须有真实引擎并发写入/回滚证据。不支持的形式应拒绝, 或使用明确的独立备份绕过并展示降低的保证。真实引擎测试是否执行以验收矩阵为准。
超时与停止准入
--timeout/SSH_TIMEOUT保留原命令/会话语义与默认值: run 默认 60s,兼容命令模式默认不设置。 拨号、握手与密码回退共享一个连接预算,回退不会重置预算; 命令 timeout 仍只针对命令。- 可选
--host-timeout覆盖已准入目标的准备、执行和验证;--global-timeout覆盖整个操作及排队。未设置新限制不会静默增加单目标期限; run 未显式覆盖全局期限时保留原推导总预算。 --fail-fast等价于--failure-mode=fail_fast;--max-failures=N达到阈值 后停止准入,冲突策略属于配置错误。已经准入的目标继续完成,可能继续失败, 因此阈值不限制最终失败总数:停止阈值记录后,最多还有concurrency-1个已准入目标可能失败。- 取消/期限独立取消活跃传输;
cancelled与timeout不同。 关闭本地 SSH 连接或终止 MCP 子进程不保证远端终止或回滚。 原生 secret API 可能不可中断,只能在调用前后检查上下文,不能靠无界后台线程 假装可取消。
MCP 仍是 stdio 单次子进程适配器;进程 watchdog 和进度投递限制是适配层边界, 不是远端执行保证。最终 JSONL 缺失/截断不能解释成 fan-out 已完成。 fan-out 在发布事件前记录准入/失败阈值。首次事件 writer 失败后停止后续发布, 返回本地 I/O 失败,但内部保留已收集目标结果;输出通道损坏不能撤销或重新定义远端结果。 在该通道缺少目标终态事件,不代表目标从未执行。
平台与证据边界
本地路径遵循客户端 OS,远端 SFTP 路径使用斜杠。POSIX mode/owner/signal 保证 不等同于 Windows ACL/进程语义。原生 Windows、真实 SQL 引擎/客户端、可隔离原生 OS keyring 是独立前提;交叉编译、模拟 SQL 客户端、测试专用 secret backend 不能证明这些边界。审计是尽力写入的本地脱敏证据; 查询诊断 区分损坏记录与空结果。本次不引入 daemon、工作流引擎、 调度器或远端驻留组件。
原生 SQL CI 提供 PostgreSQL 16 / MySQL 8.4。编译后 CLI 的
TestSQLRealEngineReliability 需显式 SSHX_E2E_REAL_SQL=1 和可销毁 loopback
测试库凭据;启用后缺客户端/服务应失败。internal/sqlsafe/real_engine_integration_test.go
独立验证 rollback/commit 与并发写入。这两个确切测试已通过会话独占的
docker exec 客户端适配器,在隔离、无网络 Docker 中对真实 PostgreSQL 17.11 /
MySQL 8.4.11 执行通过。它是生成 SQL/真实客户端/真实服务端的组件集成,
不是 mock,也不是编译 CLI 跨 SSH E2E。编译后 CLI 的原生 SQL opt-in 曾因主机缺少
psql/mysql 失败,该范围仍待自己的 CI/客户端前提。Windows 选定的 portable/
CLI 子集已在 macOS 通过,但原生 Windows 仍须 CI,本地交叉编译/平台 lint 不能替代。
前提见原生 SQL 验收。
使用场景
这一页故意放了很多例子。请把主机名当成占位符,并按你的 runbook 调整命令。
场景 1:第一次健康检查
刚拿到服务器访问权限,先做低风险检查:
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
sshx -h=prod-web -u=deploy "hostname && uptime && whoami"
这能同时验证 host trust、认证、远程用户和基本连通性,而且不会修改服务器。
场景 2:一次性添加生产主机
sshx --host-add \
--host-name=prod-web \
-h=192.168.1.100 \
-u=deploy \
-i=~/.ssh/prod-web.pem \
-pk=prod-web-sudo \
--host-desc="Production web node"
sshx --host-test=prod-web
sshx -h=prod-web "hostname"
这样后续命令不再重复 IP、用户、key 路径和 sudo key。
场景 3:只检查服务,不做变更
sshx -h=prod-web "systemctl is-active nginx"
sshx -h=prod-web "systemctl status nginx --no-pager"
自动化版本:
sshx -h=prod-web --json "systemctl is-active nginx"
场景 4:带审核地重启服务
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl restart nginx"
sshx -h=prod-web "systemctl is-active nginx"
dry-run 可以在特权变更前确认本地解释是否正确。
场景 5:检查多台机器磁盘压力
for host in prod-web prod-api prod-db; do
echo "== $host =="
sshx -h="$host" --timeout=15s "df -h / /var /data"
done
agent 友好版本:
for host in prod-web prod-api prod-db; do
sshx -h="$host" --timeout=15s --json "df -h / /var /data"
done
场景 6:为事故收集日志
sshx text -h=prod-web --path=/var/log/nginx/error.log --preset=exception --json
sshx text -h=prod-web --journal=nginx.service --since='30 min ago' --preset=error --json
mkdir -p incident-2026-07-01/prod-web
sshx -h=prod-web --download=/var/log/nginx/error.log --to=incident-2026-07-01/prod-web/error.log
先用 sshx text 现场解剖异常块;整文件下载只用于归档。
场景 7:安全上传配置
sshx -h=prod-web --upload=./nginx.conf --to=/tmp/nginx.conf
sshx -h=prod-web "sudo nginx -t -c /tmp/nginx.conf"
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
sshx -h=prod-web "sudo nginx -t"
sshx -h=prod-web "sudo systemctl reload nginx"
先暂存并验证文件,再替换生产配置。
场景 8:不同主机使用不同 sudo key
sshx --password-set=prod-web-sudo
sshx --password-set=prod-db-sudo
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl reload nginx"
sshx -h=prod-db -pk=prod-db-sudo "sudo systemctl status postgresql"
这样一个操作者可以管理多台服务器,而不需要复用一个全局 sudo key。
场景 9:验证所有已配置主机
sshx --host-test-all
轮换 key、调整 VPN、导入新的 settings.json 后,可以先跑这条命令。
场景 10:生成安全状态报告
for host in prod-web prod-api prod-db; do
sshx -h="$host" --timeout=20s --json "hostname && uptime" \
| jq --arg host "$host" '{alias: $host, success, exit_code, error_kind, stdout}'
done
脚本读取 JSON 字段,而不是解析自然语言终端输出。
场景 11:限制长命令运行时间
sshx -h=prod-web --timeout=2m "sudo apt-get update"
无人值守命令不应该无限挂住。
场景 12:诊断 host-key 失败
如果 host key 发生变化,不要先绕过。先确认为什么变化:
ssh-keygen -F prod-web
ssh-keyscan -H prod-web
只有确认机器被重建、重装或按计划轮换后,才更新 known_hosts。
场景 13:避免管道安装脚本
这种模式风险很高:
sshx -h=prod-web "curl -fsSL https://example.invalid/install.sh | sh"
更安全的模式:
sshx -h=prod-web "curl -fsSL https://example.invalid/install.sh -o /tmp/install.sh"
sshx -h=prod-web "less /tmp/install.sh"
sshx -h=prod-web "sha256sum /tmp/install.sh"
sshx -h=prod-web "sh /tmp/install.sh"
场景 14:只在必要时使用 PTY
sshx -h=prod-web --pty "sudo visudo -c"
脚本中优先使用非 PTY,因为它能保持 stdout 和 stderr 分离。
场景 15:单次敏感运行禁用审计
如果命令文本本身会暴露敏感上下文,可以只对这一次禁用审计,并在自己的 runbook 中记录原因。
SSHX_NO_AUDIT=true sshx -h=prod-web "echo redacted"
不要把它作为默认值。审计事件对事后解释很有用。
场景 16:不打开 Shell 也能检查 Docker
sshx -h=prod-web --json "docker ps --format '{{json .}}' | head -20"
sshx -h=prod-web "docker inspect nginx --format '{{.State.Status}} {{.RestartCount}}'"
这样可以收集容器状态,而不需要进入交互式 SSH,也不需要复制大量日志。
场景 17:发布前校验部署产物
sshx -h=prod-web --upload=./dist/app.tar.gz --to=/tmp/app.tar.gz
sshx -h=prod-web "sha256sum /tmp/app.tar.gz"
sshx -h=prod-web "tar -tzf /tmp/app.tar.gz | head"
只有 checksum 和压缩包内容都符合发布说明后,才继续安装。
场景 18:带回滚点地轮换服务配置
sshx -h=prod-web --upload=./service.env --to=/tmp/service.env.new
sshx -h=prod-web "sudo cp /etc/myapp/service.env /etc/myapp/service.env.bak.\$(date +%Y%m%d%H%M%S)"
sshx -h=prod-web "sudo install -m 0600 /tmp/service.env.new /etc/myapp/service.env"
sshx -h=prod-web "sudo systemctl restart myapp"
sshx -h=prod-web --json "systemctl is-active myapp"
备份、权限安装、重启和健康检查是分开的可见步骤,出错时更容易定位。
场景 19:收集最小支持包
mkdir -p support/prod-web
sshx -h=prod-web --download=/etc/os-release --to=support/prod-web/os-release
sshx -h=prod-web --audit-output=support/audit "uname -a"
sshx -h=prod-web --audit-output=support/audit "df -h"
sshx -h=prod-web --audit-output=support/audit "free -m"
除非支持工单明确需要,不要下载应用私有数据。
场景 20:远程参数像本地参数时使用 --
sshx -h=prod-web -- docker run --rm alpine:3.20 sh -c 'echo hello'
sshx -h=prod-web -- echo --force belongs-to-the-remote-command
-- 可以明确区分本地 sshx 参数和远程命令参数,避免把远程参数误当成本地开关。
场景 21:共享前先测试新的主机条目
sshx --host-add --host-name=staging-api -h=10.0.8.21 -u=deploy -i=~/.ssh/staging.pem -pk=staging-api-sudo
sshx --host-test=staging-api
sshx -h=staging-api --dry-run --json "sudo systemctl reload api"
只有命名主机能解析、能认证,并且选择了预期 sudo key 后,才把 runbook 共享出去。
场景 22:给迁移操作设置边界
sshx -h=prod-db --timeout=10s --json "pg_isready"
sshx -h=prod-db --timeout=5m --dry-run --json "sudo systemctl restart postgresql"
sshx -h=prod-db --timeout=5m -pk=prod-db-sudo "sudo systemctl restart postgresql"
sshx -h=prod-db --timeout=30s --json "pg_isready"
每一步都有时间上限,也都有机器可读结果。
场景 23:带证据地删除临时文件
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/app.tar.gz
sshx -h=prod-web --list=/tmp
删除前后都应该可见。高风险路径优先把文件移动到带日期的隔离目录,而不是直接永久删除。
场景 24:让 CI 失败时默认关闭
result="$(sshx -h=prod-web --timeout=20s --json "systemctl is-active nginx")"
printf '%s\n' "$result" | jq .
printf '%s\n' "$result" | jq -e '.success == true and .stdout == "active\n"'
当结构化结果缺失、命令失败或服务状态不符合 runbook 预期时,CI 会直接失败。
安全准则
远程执行影响很大。这些规则必须严格执行,因为一个小错误就可能修改生产系统、泄露凭据,或掩盖事故的真实原因。
不可妥协的规则
- 保持严格 host-key 校验。
- 密码保存到 OS keyring,或显式启用的加密本地保险库,不放进明文文件、shell history、工单或聊天记录。
- sudo 密码只通过 stdin 传入,绝不拼进命令字符串。
--force、--no-safety-check、--insecure-hostkey都是例外的 break-glass 选择。- 对特权或破坏性操作先跑
--dry-run。 - 自动化使用
--json和明确的退出码判断。 - 记住命令安全检查不是沙箱。
生产环境策略
对生产环境、共享 runbook、CI 作业和 agent 驱动操作,把下面这些当成策略,而不是建议:
- 使用命名主机,让审阅者能看清目标。
- 每个无人值守命令都设置
--timeout。 - 项目、迁移、发布和事故操作使用
--audit-output。 - 特权变更前必须先跑
--dry-run --json。 - 可绑定时用
--expect-plan固定已审核输入,不为使预览可绑定而放松身份/信任。 - 重试超时/取消或失败变更前,检查
change_state、可空executed、验证与备份 证据。本地连接停止或进程退出成功,都不能单独证明远端状态。 - 不要把
--force和--no-safety-check写进可复用脚本。 - 不要把
--insecure-hostkey写进可复用脚本或 CI。 - 从聊天、工单或网页复制来的命令,必须先结合目标主机和回滚方案审阅。
- 优先使用分阶段写入:上传到
/tmp,验证后再用明确权限和属主安装。 - 不可逆动作尽量一条命令一个可见步骤,避免用
&&串起大量特权变更。 - 影响生产时,在自己的 runbook 中记录维护窗口、操作者、命令、结果和回滚判断。
不要通过 shell profile、CI 变量或共享 .env 把弱安全参数变成全局默认值。break-glass 绕过必须只作用于单次命令,并且容易移除。
风险是描述而非授权:未知命令/脚本即使声明 --intent=read 也默认为 mutation。
普通 dry-run 不读取秘密或写状态;计划哈希不是远端锁,指纹不是审计签名。
详见计划、结果与安全重试。
Host-Key 信任
默认行为会防止未知或变更的 host key。使用下面的安全路径:
# 推荐:审阅目标后显式加入 host key
ssh-keyscan -H prod-web >> ~/.ssh/known_hosts
# 对受控主机接受首次信任
sshx --accept-unknown-host -h=prod-web "uptime"
避免:
sshx --insecure-hostkey -h=prod-web "uptime"
不安全 host-key 模式只适合短期受控实验环境,并且要明确记录风险。不要把它写进默认脚本或共享 runbook。
Secret 处理
使用交互式 keyring 存储:
sshx --password-set=prod-web-sudo
避免内联 secret:
sshx --password-set=prod-web-sudo:plain-text-password
内联值可能泄露到 shell history、终端滚屏、进程列表、日志或复制出去的命令里。
Keyring password key 用于 sudo 自动填充。SSH_PASSWORD 是 SSH 登录密码,应视为高风险 fallback,而不是正常操作模式。
无桌面、没有可用钥匙链的主机必须显式选择保险库:
export SSHX_SECRET_BACKEND=local-vault
export SSHX_VAULT_PASSPHRASE='…'
sshx --password-set=prod-web-sudo
本地保险库不会展示秘密值(--password-get 被拒绝)。sshx 经 stdin 注入。不要静默把秘密写成 JSON 文件。
Sudo 规则
只有远程命令以 sudo 开头时,sshx 才会自动填充 sudo:
sshx -h=prod-web -pk=prod-web-sudo "sudo systemctl reload nginx"
下面这些不会触发自动填充:
sshx -h=prod-web "sh -c 'sudo whoami'"
sshx -h=prod-web "echo sudo"
这个边界让密码查询、stdin 注入和审计字段都遵循同一条清晰规则。
安全检查只是护栏
sshx 会拦截常见破坏性操作,例如删除根目录、格式化磁盘、关机重启、修改关键系统文件、fork bomb 和 curl | sh 这类管道。
判定对象是真正要执行的命令,而不是命令串里出现的危险词。sshx 先做 shell 分段,只判断处于命令位的 token,因此只读诊断不会被误拦:
sshx -h=prod-web "last reboot -F | head -10" # 放行:reboot 是参数
sshx -h=prod-web "journalctl -u app | grep -iE 'fail|halt'" # 放行:halt 是 grep 模式
sshx -h=prod-web "sudo iptables-save | grep -F 10.0.0.0/24" # 放行:是另一个程序
sshx -h=prod-web "sudo iptables -F" # 拦截:清空规则链
包装器会被穿透解析:sudo、env、nohup、timeout、sh -c '...' 以及 docker exec <容器> ... 都会一路解析到最终执行的命令。
这并不代表不可信命令就安全了。命令校验器不可能理解所有脚本、shell 展开、应用迁移和业务数据删除路径。
绕过检查前:
sshx -h=prod-web --dry-run --json "sudo systemctl reboot"
sshx -h=prod-web --force "sudo systemctl reboot"
先确认:
- 目标主机是否正确?
- 命令是否被审阅?
- 是否有维护窗口?
- 是否有回滚方案?
- 绕过原因是否被记录?
只要有一个答案是“否”,就先停下来修 runbook。--force 的含义应该是“我已经为这个目标审阅过这条命令”,而不是“让工具别再提醒我”。
Agent 和自动化规则
自动化应该比人类终端更保守:
- 总是设置
--timeout。 - 优先使用
--json。 - 解析
success、exit_code和error_kind。 - 特权变更前先跑
--dry-run --json。 - 不要全局设置
SSH_INSECURE_HOST_KEY=1。 - 除非没有更安全路径且生命周期严格受控,否则不要通过环境变量传明文密码。
- 对项目、迁移或事故运行,使用
--audit-output保存审计事件。
审计边界
审计事件是本地 JSONL 溯源记录。它记录模式、动作、主机解析、sudo/keyring 决策、安全状态、认证方式、退出码、错误类型和耗时等元数据。
它刻意不记录:
- 明文密码。
- 私钥内容。
- stdout。
- stderr。
命令文本会作为溯源材料写入,并对常见 password/token 类参数做脱敏,但不要因此把 secret 放进命令。
探测插件信任与缓存安全
自定义探测插件是 sshx 本地运行目录(通常为 ~/.sshx/plugins/)拥有的可执行
代码。Agent skill 可以说明如何调用,但不能嵌入或维护采集脚本。
- 新建或修改后的插件默认不可信。先审阅并执行
plugin validate、plugin test, 再用plugin trust准入当前准确摘要。 - 摘要信任不是沙箱。可信采集器会以所选远端用户或 sudo 身份运行,应按已审阅 脚本对待。
- 插件代码和 sshx 凭据不会持久化到远端;采集器只通过单次 SSH 会话流式执行。
--cache=remote-prefer必须显式开启,远端只在当前认证用户的~/.sshx/observations/v1/保存规范化、已脱敏 JSON。- 缓存属于不可信输入。格式错误、超限、符号链接、权限过宽、属主不符、身份不符 或过期的记录都会被拒绝;只有在硬上限内显式请求时才允许复用陈旧记录。
- 不要把环境变量转储、原始 Compose/Nginx 配置、镜像仓库认证、cookie、token、 私钥或 secret 值放入 facts/evidence。脱敏是纵深防御,不是采集 secret 的许可。
SFTP 安全
上传到特权路径时,先暂存文件:
sshx -h=prod-web --upload=./service.conf --to=/tmp/service.conf
sshx -h=prod-web "sudo install -m 0644 /tmp/service.conf /etc/service/service.conf"
删除前先列目录:
sshx -h=prod-web --list=/tmp
sshx -h=prod-web --rm=/tmp/old-file
远程 SFTP 路径就是远程路径,不要套用本地操作系统路径规则。
事故响应检查表
当情况不对时:
- 停止用更弱的安全参数反复重试。
- 记录准确命令、退出码和
error_kind。 - 检查
~/.sshx/audit或指定--audit-output里的审计事件。 - 用
ssh-keygen -F <host>验证 host-key 状态。 - 判断失败发生在 SSH 前、认证阶段、安全校验阶段、命令执行阶段,还是输出收集阶段。
- 如果 secret 可能进入 shell history、CI 日志、issue 文本或聊天记录,立即轮换相关凭据。
共享 Runbook 的好默认值
sshx -h=<named-host> \
--timeout=30s \
--audit-output=./.sshx-audit \
--dry-run \
--json \
"sudo systemctl reload <service>"
计划审阅后,再执行真实命令:
sshx -h=<named-host> \
--timeout=30s \
--audit-output=./.sshx-audit \
-pk=<sudo-key> \
"sudo systemctl reload <service>"
故障排查
先判断失败边界:是 sshx 在远程命令运行前失败,还是远程命令已经运行但返回非零退出码?
获取结构化错误详情
sshx -h=prod-web --json "systemctl is-active nginx"
重点看:
successexit_codeerror_kindstderrauth_method
JSON 模式下,sshx 层面的失败会有 exit_code: -1 和非空 error_kind。
Host Key 错误
症状:
- 未知 host key。
- host key 发生变化。
- 认证前连接中断。
检查:
ssh-keygen -F prod-web
ssh-keyscan -H prod-web
只有确认主机符合预期后再修复。不要直接跳到 --insecure-hostkey。
认证错误
检查解析后的主机和选择的 key:
sshx -h=prod-web --dry-run --json "whoami"
常见原因:
~/.sshx/settings.json中用户写错。- per-host key 路径错误。
- key 文件权限不正确。
- 服务端不接受选择的认证方式。
- 误以为 keyring 里的 sudo 密码会当作 SSH 登录密码。
Keyring 密码用于 sudo 自动填充,不会被静默用作 SSH 登录密码。
Sudo 没有自动填充
只有命令以 sudo 开头,sshx 才会自动填充。
可以触发:
sshx -h=prod-web -pk=prod-web-sudo "sudo whoami"
不会触发:
sshx -h=prod-web "sh -c 'sudo whoami'"
检查 password key 是否存在:
sshx --password-check=prod-web-sudo
无桌面主机常常没有 Secret Service。sshx 不会静默改写文件。需要显式启用:
export SSHX_SECRET_BACKEND=local-vault
export SSHX_VAULT_PASSPHRASE='…'
sshx --password-set=prod-web-sudo
sshx --password-check=prod-web-sudo
本地保险库拒绝 --password-get。dry-run JSON 应出现 secret_backend: "local-vault"。
命令被阻止
通常是安全检查失败。
sshx -h=prod-web --dry-run --json "sudo rm -rf /"
如果特权或破坏性命令确实是预期操作,先审阅、记录原因,再只对这一次使用 --force。
Login 后输入一个字母出现好几个
症状:sshx login 之后每按一个键,屏幕上会出现多个相同字符;多出来的往往是暗色、灰色或粉色。Tab 补全或 zsh-autosuggestions 也会错乱。
先升级 sshx。旧版本申请 PTY 时只设置了 echo,远端 zsh 可能把提示符宽度算错,从而把刚输入的字符又画一遍。
若升级后在 Cursor / VS Code 集成终端里仍复现,多半是终端的 local echo(预显示)。它会给 vim/tmux 自动关掉,但不会给 sshx 关,于是和 zsh-autosuggestions 叠在一起:
{
"terminal.integrated.localEchoExcludePrograms": [
"vim",
"vi",
"nano",
"tmux",
"ssh",
"sshx"
]
}
彻底关掉该功能:"terminal.integrated.localEchoEnabled": false。
在同一个终端里对比 ssh:如果 OpenSSH 也花屏,问题在终端模拟器,不在 sshx。
脚本卡住
设置 timeout:
sshx -h=prod-web --timeout=30s --json "long-running-command"
如果命令必须要终端语义,可以使用 --pty,但 PTY 模式不适合结构化自动化。
JSON 输出无法解析
普通 JSON 模式下,stdout 应该只包含一个 JSON 对象,诊断信息走 stderr。检查这些问题:
- 是否使用了
--pty。 - 外层脚本是否在
sshx前后打印了额外文本。 - 调用方是否混合了 stdout 和 stderr。
SFTP 路径问题
本地文件使用本地路径规则。远程目标使用斜杠分隔的远程路径:
sshx -h=prod-web --upload=./file.txt --to=/tmp/file.txt
审计事件缺失
检查是否禁用了审计:
env | grep SSHX_NO_AUDIT
检查默认输出位置:
ls ~/.sshx/audit
如果使用项目内目录:
sshx -h=prod-web --audit-output=./.sshx-audit "uptime"
ls ./.sshx-audit
command not found
检查安装:
command -v sshx
sshx --version
如果通过 Go 安装,确认 ~/go/bin 或 GOPATH/bin 已加入 PATH。
sshx 项目画像与方向
SSH is the channel. X is execution.
SSH 是通道,X 代表执行。
项目定位
sshx 是一个面向 Agent 的远程主机执行工具。它以 SSH/SFTP 作为已有、成熟、普遍可达的可信通道,把 Agent 的执行意图转换为一次边界清楚、结果可判断、过程可审计的远程主机操作。
这一定义刻意把产品重心从“SSH 客户端”移到“远程执行”上:
- SSH 是通道:负责连接、认证、加密、主机身份校验和文件传输,但不是 sshx 的全部产品价值。
- X 是执行:负责目标解析、执行预览、安全检查、命令或文件动作、结果结构化、失败分类和审计留痕。
- Agent 是首要调用者:CLI 不是纯人类交互界面,而是一份稳定的进程级工具契约;人类运维者与 Agent 共用同一套目标、安全和审计语义。
一句话定位:
让 Agent 通过 SSH,高效、安全、可审计地在远程主机上完成任务。
英文定位:
Agent-native remote execution over SSH.
项目概述
Agent 操作远程主机时,真正的困难通常不在“能否建立 SSH 连接”,而在于如何稳定地回答以下问题:目标是哪台主机、使用什么身份、将执行什么、是否越过安全边界、执行是否成功、失败发生在哪一层、事后能否解释这次操作。sshx 应把这些重复且高风险的细节收敛为一个短命令和一份稳定结果。
项目保持单二进制、跨平台、无远端驻留组件的形态。每次调用解析目标和约束,建立 SSH/SFTP 连接,执行一个明确动作,返回结果并退出;本地配置、系统 keyring、known_hosts 和审计记录共同构成执行所需的最小信任环境。
Agent / 自动化 / 人类运维者
|
v
Agent 契约层
- CLI / JSON / 退出码 / error_kind
- dry-run / timeout / audit context
|
v
X 执行层
- 目标发现与解析
- 动作分类与安全检查
- 命令、SFTP、主机间传输
- 结果归一化与审计留痕
|
v
SSH 通道层
- 加密连接与认证
- host-key 信任
- SSH exec / SFTP
|
v
远程主机
本地信任边界:settings.json / OS keyring 或显式 local vault / known_hosts / audit JSONL
项目画像(目标状态)
sshx 做好之后,应成为 Agent 工具箱里的“远程执行基本件”:像调用本地进程一样容易组合,又明确承认远程操作具有凭据、权限、网络和破坏性副作用。Agent 不需要模拟交互式终端,不需要从自然语言日志猜测结果,也不需要在每个任务中重新拼装 SSH 参数、sudo 注入、安全检查和审计逻辑。
效率画像
效率不是单纯缩短 SSH 握手时间,而是减少 Agent 完成一次可靠远程操作所需的决策、调用和返工:
- 意图表达短:命名主机和可复用配置把地址、端口、用户、key 与 sudo key 收敛到稳定目标名。
- 一次调用闭环:发现或解析目标、执行动作、返回结构化结果、形成审计记录,不要求 Agent 维持交互会话。
- 机器判断直接:stdout、stderr、退出码、
success与error_kind各自职责稳定,Agent 不依赖脆弱文本匹配。 - 执行前少返工:dry-run 能在连接、读取 secret 或修改状态前暴露目标解析、sudo、安全绕过和副作用意图。
- 失败后快恢复:错误能区分配置、连接、认证、host-key、超时、命令退出和安全阻断,避免盲目重试。
- 数据移动少落地:远端到远端传输可以流式中转,不要求先写入本地磁盘。
安全画像
安全不是一句“危险命令检测”,而是一组贯穿执行生命周期的边界:
- 凭据边界:secret 默认进入 OS keyring,不进入配置、命令字符串、审计记录或普通终端回显。
- 通道边界:默认严格校验 host key,未知或变更的远端身份不会被静默接受。
- 权限边界:SSH 登录身份与 sudo secret 语义分离;特权执行必须可识别、可预览、可追溯。
- 动作边界:明显破坏性操作默认阻断;绕过必须显式且进入结果与审计上下文。
- 副作用边界:Agent 在执行前能知道动作是否会连接、读取 secret、修改本地状态或修改远端状态。
- 责任边界:记录足够解释“谁通过什么入口,对哪台主机,以何种安全上下文做了什么,结果如何”,同时默认不持久化敏感输出。
安全、正确性与可审计性高于便利和吞吐;在发生冲突时,宁可要求调用者显式表达意图,也不静默降级信任边界。效率优化必须减少无意义摩擦,但不能用模糊目标、隐式凭据选择或不可解释的并发换速度。
产品边界画像
sshx 是执行工具,不是远程主机上的 Agent,也不是持续运行的控制平面。它应在现有 SSH 基础设施上提供一个清晰的 Agent 执行契约,而不是要求每台服务器安装新服务。它可以支持受控的批量执行和更强的执行描述,但不承担期望状态管理、工作流编排、资产治理或组织级审批系统的全部职责。
当前能力清单
-
人类交互登录
sshx login <name> [--sudo]把本地 TTY 接到已解析主机上的交互会话;--sudo经 stdin 注入 keyring sudo 秘密后进入特权 login shell。--json仅配合--dry-run。不进入 MCP,不支持多主机。POSIX only。证据:internal/app/login.go、internal/sshclient/login.go。 -
单次远程命令执行
支持
sshx -h=<host> [options] <command>,默认不启用 PTY,保持 stdout/stderr 分离,并透传远程命令退出码。支持 timeout、显式 PTY 和 sudo stdin 注入。证据:internal/app/app.go、internal/app/config.go、internal/sshclient/client.go、internal/sshclient/runcommand_test.go。 -
Agent 结构化结果契约
--json输出单个 JSON 对象,包含成功状态、退出码、输出、耗时、认证方式和error_kind;sshx 自身失败与远程命令失败可以区分。证据:internal/app/app.go、internal/app/agentmode_test.go、internal/app/usage.go。 -
执行计划预览
--dry-run在建立连接、执行动作、读取 keyring secret、更新known_hosts或写配置前生成本地执行计划;可与--json组合供 Agent 判断。证据:internal/app/dryrun.go、internal/app/agentmode_test.go、internal/app/transfer_test.go。 -
命名主机发现与管理
~/.sshx/settings.json保存命名主机、地址、端口、用户、key 和 password key;支持增删改查、单台或全量连接测试,并可从~/.ssh/config选择性、全有或全无地导入合格主机。配置写入使用私有权限和原子替换。证据:internal/app/settings.go、internal/app/host_manager.go、internal/app/sshconfig.go、internal/app/settings_test.go、internal/app/sshconfig_test.go。 -
SFTP 与主机间文件执行动作
支持上传、下载、列表、建目录、删除,以及两台远端主机之间经本机流式中转的文件或目录传输。证据:
internal/sshclient/client.go、internal/sshclient/transfer.go、internal/app/transfer.go、internal/app/transfer_test.go。 -
凭据与认证边界
密码默认存放在系统 keyring;无桌面环境可显式启用加密本地保险库(
SSHX_SECRET_BACKEND=local-vault,文件$SSHX_HOME/vault)。保险库只写不读,执行时经 stdin 注入。默认优先 SSH key,只有显式提供 SSH 登录密码时才回退密码认证;命名主机可独立选择 SSH key 和 sudo password key。没有从钥匙链到文件的静默降级。证据:internal/keyringstore/、internal/app/password.go、internal/sshclient/client.go、tests/e2e/keyring_e2e_test.go、tests/e2e/vault_e2e_test.go。 -
通道信任与动作护栏
默认通过
known_hosts严格校验 host key;未知主机接受和不安全校验必须显式开启。明显破坏性命令默认被拦截,--force/--no-safety-check是显式绕过。命令位上的psql/pgcli/sqlite3被导向sshx sql。证据:internal/sshclient/client.go、internal/sshclient/validate.go、internal/sshclient/client_test.go、internal/sshclient/validate_test.go。 -
受控 SQL 执行
sshx sql通过远端已有的psql或sqlite3执行恰好一条语句:本地 fail-closed 分类、策略门闩、变更前备份、结构化 JSON 与审计。PostgreSQL 另有 EXPLAIN 行数估计、表锁事务备份和容器凭据发现;SQLite 以绝对文件路径为身份,只读走file:?mode=ro,变更在BEGIN IMMEDIATE下做表 CSV 或整文件.backup。证据:internal/app/sql.go、internal/sqlsafe/、tests/e2e/sql_sqlite_e2e_test.go。 -
受控文件 Apply
sshx apply替换一个远程正则文件:绝对路径门闩、可选--expect-sha256前置条件、默认 owner-only 备份、同目录临时文件 + rename、保留权限/所有者。--sudo先经 SFTP 暂存再特权安装。不包含 nginx -t 或 reload。证据:internal/app/apply.go、internal/sshclient/apply.go、tests/e2e/apply_e2e_test.go。 -
stdio MCP server
sshx mcp通过 stdio 提供 Model Context Protocol 工具面:sshx_run、sshx_sql、sshx_apply、sshx_inspect、sshx_sftp、sshx_transfer、sshx_host_list与 CLI 契约 1:1 映射,每次 tool call 以一次性子进程重新进入 sshx,结果就是 CLI 的版本化 JSON;force/bypass_reason 必须显式传参,密码管理不暴露,审计事件带entry=mcp标记。证据:internal/app/mcp.go、internal/app/mcp_test.go、tests/e2e/mcp_e2e_test.go。 -
本地结构化审计
非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:
internal/app/audit.go、internal/app/audit_test.go。 -
内置主机环境探测
sshx inspect用一次 SSH 连接返回带来源、权限、目标身份和新鲜度的观察结果;内置系统身份、资源、网卡、路由、DNS、监听端口、防火墙和组合基线能力。证据:internal/app/inspect.go、internal/plugin/builtin.go、tests/e2e/inspect_plugin_e2e_test.go。 -
sshx 本地插件生命周期
Agent 可通过
sshx plugin create在~/.sshx/plugins/(或$SSHX_HOME/plugins/)创建 Docker、Nginx 或自定义应用探测插件,也可用sshx plugin install <dir>在 CLI 内完成已有插件目录的暂存、校验、发布与--trust,并完成 list/show/validate/test/trust/remove。plugin list区分内置能力与本地插件并始终打印本地插件根目录;插件缺失时报错指出实际搜索目录。插件脚本不由 Agent skill 维护;摘要变化会使信任失效。证据:internal/app/plugin.go、internal/plugin/、tests/e2e/inspect_plugin_e2e_test.go。 -
有界远端观察快照
显式启用
--cache=remote-prefer后,仅把规范化、已脱敏 JSON 保存到远端用户~/.sshx/observations/v1/。复用绑定插件版本/摘要、host key、平台、UID、boot ID、权限、参数与 TTL;缓存作为不可信输入校验,使用私有权限和原子替换。证据:internal/app/inspect.go、internal/plugin/observation.go、internal/sshclient/remote_state.go、tests/e2e/inspect_plugin_e2e_test.go。 -
跨平台交付
项目以单二进制形式面向 Linux、macOS 和 Windows,支持 Go 安装、安装脚本、Release 产物和 Homebrew tap。证据:
Makefile、.github/workflows/ci.yml、.github/workflows/release.yml、install.sh、install.ps1。
非目标(铁律)
-
不把 SSH 本身重新实现一遍。 不追求交互式 shell 复用、通用端口转发、SOCKS、X11 或 agent forwarding;SSH 是底层通道,不是功能竞赛对象。
-
不在远端安装驻留 Agent 或插件运行时。 不引入守护进程、后台服务、连接池或常驻控制面;采集器只在单次 SSH 会话中流式执行。远端可显式保存有版本、有时效的被动 JSON 观察结果,但不保存插件代码或可执行运行时。
-
不成为 Ansible、Salt 或工作流引擎。 可以提供有界的多主机执行,但不引入期望状态语言、playbook 生态、调度系统或长期任务编排。
-
不做 HTTP/SSE MCP server、守护进程或常驻协议服务。 stdio MCP server(
sshx mcp)在范围内:它由 MCP 客户端拉起并随会话生灭,每个 tool call 都以一次性子进程重新进入 sshx,复用同一套契约、安全门禁与审计。不得添加 HTTP/SSE 传输、监听端口或任何寿命超过其客户端的服务。 -
不把危险命令防护宣传成沙箱。 sshx 降低误操作和凭据泄露风险,但不承诺安全执行恶意或不可信命令。
-
不成为 CMDB、企业 secret vault 或 SIEM。 sshx 可消费主机配置、使用本地 secret backend、生成审计证据,但不替代组织级资产、密钥和合规平台。
-
不提供明文 secret 存储,也不静默放松 host-key 校验。 便利性不能突破凭据与通道信任边界。本地保险库必须加密、显式选择,且不得从钥匙链失败自动掉进文件。
-
不做 GUI/TUI。 核心交互面保持为 flags、stdin、stdout、stderr、退出码和结构化文件;图形化体验属于外部工具。
-
不为了局部平台能力牺牲跨平台一等支持。 Linux、macOS 和 Windows 的核心执行契约必须保持一致。
方向与意图
-
把“执行单元”变成稳定产品契约
每次执行都应能明确表达目标、动作、约束、副作用、安全上下文和结果。无论动作是命令、文件操作还是未来的批量执行,Agent 都能用同一套心智模型预览、执行、判断和审计,而不需要理解内部 SSH 细节。
-
降低复杂命令与脚本的传递损耗
远程命令经本地 shell、参数解析和远端 shell 多层解释时容易发生引用、通配和变量展开损坏。sshx 应让 Agent 能可靠传递复杂执行内容,并保持“实际执行内容”在 dry-run、审计和结果中的语义一致;具体输入形态由实现阶段选择。
-
建立有界的多主机执行能力
Agent 应能对主机集合执行同一检查或动作,并得到逐主机、可聚合、可部分失败的结构化结果。并发必须有界,目标集合必须可预览,失败不能被总成功状态吞掉;该方向服务于执行效率,但不演变成持续编排平台。
-
从命令黑名单走向可解释的执行治理
在现有危险命令防护之外,逐步增强动作分类、只读与变更意图表达、安全绕过原因、调用来源或 run ID 等上下文,使人类审批层或上层 Agent 能基于明确证据决策。治理信息不得伪装成绝对安全保证。
-
强化目标与身份的可发现性
主机导入、列表、分组、标签、连接健康和凭据引用应让 Agent 快速找到正确目标,同时避免把基础设施秘密复制到更多位置。规模化体验仍以简单、可审阅、可迁移的本地配置为底线。
-
把重复探索收敛为可复用探测能力
固定的操作系统与网络事实由二进制内置,Docker、Nginx 和私有应用由 sshx 运行目录中的本地插件表达。每个能力都应有严格 manifest、结果 schema、摘要信任、权限策略、脱敏与有效期,让 Agent 先读可信的新鲜观察,再决定是否重新探测,而不是跨任务重复拼装命令。
-
提升失败恢复效率
连接、认证、host-key、权限、超时、远程退出、部分传输和本地持久化失败应拥有稳定分类与足够上下文。对于会修改状态的动作,结果需要帮助调用者判断“未开始、部分完成、已完成但回执异常”,减少危险重试。
-
扩展文件与受控网络边界执行
继续完善递归文件操作、传输完整性和失败恢复;在需要进入私有网络时,可考虑受控的 jump-host 能力,但不得扩展成通用隧道产品,也不得模糊每一跳的 host-key 与认证决策。
-
保持 secret backend 可演进
默认信任根仍是 OS keyring。无桌面主机的加密本地保险库已在范围内:secret 不落明文、不进入命令字符串、不静默降级、用途可区分,Agent 只引用凭据而非读取凭据。未来若再接入其他 backend,必须保持同一契约,且不得变成组织级 secret 平台。
完成的样子
sshx 的成功不以支持多少 SSH flag 衡量,而以 Agent 能否用更少步骤完成一次可信远程执行衡量。
- Agent 用一个稳定目标名和一个明确动作即可发起执行,不需要重复处理地址、端口、用户、key、sudo secret 与 host-key 细节。
- dry-run 所展示的目标、动作、安全绕过和副作用,与真实执行及审计记录保持同一语义。
- 人类输出清楚,机器输出稳定;所有一级失败都有可分支的类别,远程命令失败不会与 sshx 自身失败混淆。
- 明显危险动作默认受阻,特权执行与安全绕过显式可见;secret 不出现在普通配置、命令拼接、审计记录或默认终端回显中。
- 多主机执行即使部分失败,也能逐主机说明状态,并避免不受控并发和盲目重试。
- 会修改远端状态的操作能够说明是否执行、是否部分完成以及下一步如何安全判断,而不是只返回一个模糊 EOF 或通用错误。
- 项目继续保持单二进制、无远端驻留组件、无常驻协议服务(stdio MCP 随客户端会话生灭)、无长期控制面的轻量边界。
- Agent 能在 sshx 运行目录快速创建、测试和信任应用探测插件;skill 只维护调用方法,不维护插件脚本。
- 常见系统/网络或应用部署探索可在一次调用中形成可复用观察,且陈旧、身份漂移或不可信缓存不会被静默采用。
- 每项一级能力都有覆盖真实 CLI 与真实 SSH/SFTP 边界的验收证据;安全与状态修改路径同时覆盖失败和恢复语义。
验收矩阵(业务能力覆盖矩阵)
覆盖底线(硬性规定):
- 每个一级功能至少有一条 Happy Path E2E。
- 每个高风险功能至少覆盖一条失败路径。
- 每个涉及权限的功能至少验证两种角色或权限状态。
- 每个会修改系统状态的操作至少验证一次失败后的恢复或回滚。
- 每次新增一级业务功能,必须同步新增对应 E2E 并更新本矩阵。
当前仓库已建立 tests/e2e 编译后二进制验收套件:测试进程通过真实 TCP SSH/SFTP 协议连接隔离服务端,并从进程退出码、stdout/stderr、JSON、远端文件/状态、known_hosts、settings、keyring 和审计 JSONL 观察结果。默认 keyring 场景使用仅在 sshx_e2e 构建标签下启用的隔离后端;macOS CI 还会创建临时系统 Keychain,验证生产二进制跨真实 OS keyring 的完整生命周期。组件测试不计作 CLI E2E,表内证据按实际边界标注。
下表记录既有测试覆盖位置,不代表本次工作已在所有 OS/SQL 引擎上执行通过。 特别是通过真实 SSH 调用模拟 SQL 客户端,仍不能证明真实数据库事务正确。 issue #71 的新增边界、验证状态及外部前提单列在后面的证据矩阵。
| 一级功能 | 风险 | 权限 | 修改状态 | Happy Path E2E | 失败路径 | 权限状态覆盖 | 失败恢复/回滚 | 现有证据 |
|---|---|---|---|---|---|---|---|---|
| 单次远程命令执行 | 高 | 是 | 可能 | ✅ | ✅ 远端非零/超时/异常断连 | ✅ operator/reader | ✅ 部分完成后重读状态 | tests/e2e/cli_e2e_test.go |
| Agent JSON / 退出码契约 | 高 | 否 | 否 | ✅ | ✅ stdout/stderr、远端非零与分类失败 | 不适用:只描述结果 | 不适用:不修改状态 | tests/e2e/cli_e2e_test.go |
| dry-run 执行预览 | 中 | 否 | 否 | ✅ | ✅ 组件级无效计划 | 不适用:不获取远端权限 | ✅ 证明零连接、零信任/审计写入 | tests/e2e/cli_e2e_test.go、internal/app/agentmode_test.go |
| 命名主机管理与 SSH config 导入 | 中 | 否 | 是,本地 | ✅ 导入后按别名执行 | ✅ 选择项缺失 | 不适用:单用户本地配置 | ✅ 失败选择不部分写入 | tests/e2e/host_audit_e2e_test.go |
| SFTP 上传/下载/目录操作 | 高 | 是 | 是,远端 | ✅ | ✅ 只读端拒绝写入 | ✅ operator/reader | ✅ 失败上传无目标残留 | tests/e2e/sftp_e2e_test.go |
| 远端到远端传输 | 高 | 是,两端 | 是,目的端;源端只读 | ✅ | ✅ 目标端只读 | ✅ 可写/只读目标 | ✅ 失败无残留,改用可写端重试;不证明目录整体回滚 | tests/e2e/sftp_e2e_test.go |
| keyring 凭据管理与认证回退 | 高 | 是 | 是,本地 secret | ✅ | ✅ 缺失 secret/公钥被拒 | ✅ key/password-fallback、stored/missing | ✅ 删除后缺失;可重新设置 | tests/e2e/keyring_e2e_test.go、tests/e2e/cli_e2e_test.go |
| 加密本地保险库 | 高 | 是 | 是,本地 secret | ✅ set/check/sudo 注入 | ✅ get 拒绝、错误口令、过宽权限 | ✅ 0600 可读 / 0644 拒绝 | ✅ 失败写入保留原 vault 字节 | tests/e2e/vault_e2e_test.go、internal/keyringstore/vault_test.go |
| host-key 校验 | 高 | 是,信任状态 | 可能修改 known_hosts | ✅ 显式信任后严格复用 | ✅ 未知/变更 key | ✅ strict/accept-unknown | ✅ 首次写入后重新严格连接 | tests/e2e/cli_e2e_test.go |
| 危险动作阻断与显式绕过 | 高 | 是 | 否,仅控制执行准入 | ✅ 显式 --force | ✅ 默认阻断且零连接 | ✅ 默认阻断/显式绕过 | 不适用:策略门本身不修改状态 | tests/e2e/cli_e2e_test.go |
| 本地结构化审计 | 高 | 否 | 是,本地 | ✅ | ✅ 不可写目标可观测 | 不适用:本地调用者同权 | ✅ 修复目标后单事件写入 | tests/e2e/host_audit_e2e_test.go |
| 本地探测插件生命周期 | 高 | 本地调用者权限 | 是,本地 | ✅ create/install/list/show/validate/test/trust/remove | ✅ 路径逃逸、重复创建、symlink/非普通文件源、安装前校验失败、manifest/entrypoint/schema/fixture 分类失败 | ✅ 私有目录/文件权限 | ✅ replace/remove 保留可恢复备份;安装校验失败不触碰已发布插件 | tests/e2e/inspect_plugin_e2e_test.go、internal/plugin/install_test.go |
| Agent Skill 安装 | 高 | 本地调用者权限 | 是,本地 Agent 信任目录 | ✅ 编译后二进制离线安装/幂等复用 | ✅ 内容冲突与 symlink 目标拒绝 | ✅ 默认目录/显式目录 | ✅ 冲突不覆盖,显式 force 后恢复官方版本 | tests/e2e/skill_e2e_test.go |
| 单主机探测与内置基线 | 高 | 是 | 否,cache off | ✅ 自定义插件与 system.baseline | ✅ 未信任、污染/超限输出、超时、非零退出、不支持平台 | ✅ operator/reader/sudo-required | 不适用:不修改远端状态 | tests/e2e/inspect_plugin_e2e_test.go、tests/e2e/keyring_e2e_test.go |
| 远端观察缓存 | 高 | 是 | 是,远端 JSON | ✅ 冷写入/热复用/并发原子替换 | ✅ TTL/boot ID、格式、大小、属主、权限、symlink、只读端 | ✅ 可写/只读 SFTP | ✅ 失败写入保留原有效快照 | tests/e2e/inspect_plugin_e2e_test.go |
| 有界多主机执行 | 高 | 是 | 可能,多主机 | ✅ sshx run 组/标签选择 + concurrency 1/4/8/32 | ✅ fail_fast、部分失败、零匹配 | ✅ operator 密码角色 | ✅ 每个选中目标都有终态事件 | tests/e2e/run_e2e_test.go、internal/execution/*_test.go |
| 可解释执行治理 | 高 | 是 | 可能 | ✅ run 契约 dry-run/digest/intent/bypass_reason | ✅ blocked、uncertain completion、typed error.kind | ✅ SSH login vs sudo key 分离 | ✅ completion 指导 verify_first/unsafe | tests/e2e/run_e2e_test.go、internal/app/run.go、internal/execution |
| 受控 SQL 执行(SQLite) | 高 | 是 | 是,远端库 | sqlite3 可用时:只读查询与带备份 UPDATE | 直连客户端阻断、ATTACH 分类拒绝、缺路径 | operator SSH 密码角色;非完整 DB 权限矩阵 | UPDATE 前 CSV 可还原旧值;不等于全部失败注入 | tests/e2e/sql_sqlite_e2e_test.go、internal/sqlsafe/*_test.go、internal/app/sql_test.go |
| 受控文件 Apply | 高 | 是 | 是,远端文件 | ✅ 创建/覆盖/幂等 | ✅ 哈希不匹配、符号链接、只读端 | ✅ operator/reader | ✅ 覆盖前备份可还原旧值 | tests/e2e/apply_e2e_test.go、internal/app/apply_test.go、internal/sshclient/apply_test.go |
| stdio MCP 工具面 | 高 | 是 | 可能,经子进程 | ✅ initialize/tools/list/tools/call 真实执行 | ✅ force 缺 bypass_reason 被拒、非法输入本地拒绝 | ✅ operator 密码角色 | ✅ dry-run 零连接;审计 entry=mcp 可追溯 | tests/e2e/mcp_e2e_test.go、internal/app/mcp_test.go |
| 审计查询/导出 | 中 | 否 | 查询无写入;导出写本地目标 | ✅ execute → query by run_id | ✅ 空结果 exit 0 | 不适用:本地调用者 | 不改写源审计文件 | tests/e2e/host_audit_e2e_test.go、internal/app/audit_query_test.go |
| 受控 SQL(MySQL) | 高 | 是 | 是,远端库 | 真实 SSH + 模拟 mysql 客户端;非真实引擎 | LOAD DATA / INTO OUTFILE 分类拒绝;run-mode mysql 阻断且零连接 | operator SSH 密码角色;非真实 DB 权限验证 | 模拟快照含旧值,不证明真实引擎回滚/原子性 | tests/e2e/sql_mysql_e2e_test.go、tests/e2e/fake_mysql.py、internal/sqlsafe/mysql_test.go |
| 受控 jump-host | 高 | 是,每跳独立 | 否,无本地监听 | ✅ 经 bastion 执行;dry-run 展示 via | ✅ 未知 via / 环 / 跳板认证失败且目标零连接 | ✅ jump 与 target 各自认证 | 调用结束 hop 关闭;不提供隧道启停 | tests/e2e/jump_e2e_test.go、internal/sshclient/connect_jump_test.go、internal/execution/jumps_test.go |
| 远端文本解剖 | 中 | 是 | 否 | ✅ SFTP 异常块命中;journal 受控 argv | ✅ 相对路径拒绝、dry-run 零连接 | ✅ operator SFTP;sudo 文件/journal | 不适用:只读 | tests/e2e/text_e2e_test.go、internal/textsafe/*_test.go、internal/app/text_test.go |
不能据此声称完整平台/引擎验收已通过。任何一级能力不得只以参数解析或组件测试 作为完成依据;必须补充相应外部边界 E2E,并如实记录不可用环境与未证明的保证。
Issue 71 execution hardening evidence
本轮是执行原语加固,不是产品扩张;上述非目标不变。公开契约见 English / 中文。 “测试已存在”与“本次已执行通过”分开,不把跨平台编译、mock 或跳过计为真实验收。
| Requirement / 要求 | In-tree evidence / 仓库证据 | Boundary / 验证边界与前提 |
|---|---|---|
| Canonical plan/risk / 规范计划与风险 | internal/execution/plan_test.go、risk_test.go、internal/app/plan_test.go | 本地向量/准入测试;整个信任记录快照保守失效,不承诺仅相关记录失效 |
| Legacy contract fixtures / 既有契约 fixture | tests/e2e/contract_golden_e2e_test.go、tests/e2e/testdata/contract/ | 7 份增量兼容规范化 v1 JSON/JSONL fixture;不证明真实数据库事务 |
| Bound CLI, offline rejection / 绑定与离线拒绝 | tests/e2e/plan_e2e_test.go | 编译后二进制、真实 SSH;DNS、缺公钥/信任、远端 SQL 身份应拒绝绑定;完整各动词矩阵仍需逐项证据 |
| Effect/verification evidence / 副作用验证 | apply / SQL / SFTP 的领域测试与共享元数据 | 未知不可当 false;任意程序副作用、远端状态锁不在保证内 |
| Invocation/target identity / 执行关联 | internal/execution/lifecycle_test.go、tests/e2e/plan_e2e_test.go | 身份/指纹为脱敏关联,不是签名、防重放或防篡改日志 |
| Deadline/fan-out admission / 期限与准入 | internal/execution/executor_test.go、lifecycle_test.go、internal/sshclient/transport_reliability_test.go | 本地并发/协议故障测试;阈值只停止准入,不保证远端终止 |
| Compiled-binary faults / 二进制故障路径 | tests/e2e/reliability_e2e_test.go | 缺失/损坏/加密 key、认证回退、信任损坏/变更/只读、损坏 settings、HOME/USERPROFILE 隔离;不自动证明所有传输阶段、原生 OS 或数据库故障 |
| Apply recovery / 文件恢复 | tests/e2e/apply_e2e_test.go、internal/sshclient/apply_test.go | 服务端 rename 与 sudo 工具前提;SFTP 不是任意写入者 CAS |
| File transfer effects / 传输副作用 | tests/e2e/sftp_e2e_test.go | 单文件/部分目录证据;不承诺目录整体原子回滚或大小即内容相同 |
| SQL CLI engine effects / SQL CLI 效果 | tests/e2e/reliability_sql_native_e2e_test.go、SQLite E2E;旧 MySQL 模拟测试另列 | PostgreSQL/MySQL read/update/no-op/zero、备份回读、reader 拒绝/恢复 fixture 已存在;编译后 CLI 原生 opt-in 缺主机 psql/mysql 而失败,仍待 CI/原生客户端 |
| SQL transaction engines / SQL 引擎事务 | internal/sqlsafe/real_engine_integration_test.go | 隔离 Docker 中真实 PostgreSQL 17.11 / MySQL 8.4.11:RollbackAndCommit、ConcurrentWriterExcludedFromPreimage 均通过;是生成 SQL/真实客户端/真实服务端组件集成,不是编译 CLI SSH E2E |
| Audit diagnostics / 审计诊断 | internal/app/audit_query_test.go、audit_test.go、tests/e2e/host_audit_e2e_test.go | best-effort 持久化独立于执行结果;损坏行与真实读写错误不可吞掉 |
| MCP parity / MCP 一致性 | internal/app/mcp_test.go、tests/e2e/mcp_e2e_test.go | stdio 子进程,受 watchdog/进度通道限制;中断流不能伪造完成 |
| Native platforms / 原生平台 | .github/workflows/ci.yml 与平台测试 | 原生 Windows 执行、可隔离 keyring、POSIX 权限/信号各自验证;交叉编译不能替代 |
2026-09-05 实现验证记录:macOS 执行器 go test -race ./internal/execution
通过,准入/期限/身份/投递选择器重复 20 次通过;后续 peer 身份投影测试验证各字段
变化影响目标及父指纹、执行 ID/计划关联保持稳定且 ToResult 保留同一元数据,
并再次通过 executor race。这些是确定性 barrier、fake/cancel-aware
dialer 与执行观测映射的组件证据,不涉及外部 SSH 认证、真实数据库或原生 Windows。
文档/帮助的 go test ./internal/app -run '^TestPrintUsage' -count=1 也已通过。
SQL 包 race、真实 SQLite 事务/证据测试与该范围 lint 已通过;
SQLite 的本地证据本身不证明 PostgreSQL/MySQL,后者证据另列。
传输范围 go test -race ./internal/sshclient、Transport/SFTPReliability/
TransferReliability race 选择器重复 5 次通过,范围 lint 为 0 issues。
transport_reliability_test.go 覆盖 loopback 协议取消、rekey、信任/签名 pin、
输出上限、权限、短复制恢复、缺原子原语、部分目录和双端中转取消;Windows 仅
测试交叉编译,不代表原生 Windows,也不证明引擎/审计/MCP 边界。
该记录不扩展为全部外部边界或所有平台已通过。
可靠性实现环境中 Windows 仅交叉编译与平台 lint,原生执行等待 CI。Windows CI
选择的 Portable/Reliability/ContractGolden/CLI 认证、streams、failures、safety、
dry-run、skill 子集已在 macOS 通过;不能算原生 Windows 通过。冻结 golden/认证/信任
也在未修改 HEAD 快照上通过;settings 测试与 plugin/skill/keyring race 测试通过。
MCP 范围的 app 定向测试与 tests/e2e 的全部 TestMCP 生产二进制测试已在
macOS 通过;Windows/Linux app-test 仅完成交叉编译,不能据此宣称原生 MCP 验证。
审计范围完整 race 测试已在集成代码上通过(无临时 overlay 替换),覆盖查询诊断、
部分有效记录、执行关联与 apply/SQL 证据保留。
编译后 CLI 的原生 PostgreSQL/MySQL opt-in 曾因缺少本地 psql / mysql
失败,没有回退到 fake;该 CLI SSH E2E 范围仍待 CI/原生客户端。
随后通过会话独占、无网络的 Docker fixture 和 docker exec 客户端适配器,
运行实际 PostgreSQL 17.11 / MySQL 8.4.11,两个引擎的
TestSQLRealEngineRollbackAndCommit 与
TestSQLRealEngineConcurrentWriterExcludedFromPreimage 均通过。
这是生成 SQL、真实客户端、真实服务器及隔离前镜像文件的组件集成,
不是 mock,也不是编译后 CLI 跨 SSH E2E。没有使用生产资源或源码挂载。
macOS 原生 keyring 只有明确隔离 fixture、且默认与唯一
搜索列表 keychain 同时匹配才允许测试,不触碰用户日常钥匙链。
可按需要运行现有选择器,而不是为文档修改启动全套测试:
go test ./internal/app -run '^TestPrintUsage' -count=1
go test ./internal/execution -run 'Test.*(Plan|Risk|Lifecycle|Failure)' -count=1
具体引擎/平台测试的命令、是否跳过和外部服务前提,应随实现验证记录更新; MySQL 保证限于受支持策略与实际验证的 rollback/commit、并发写入案例, 不能推广为所有语句/引擎或整个 CLI 流程的通用原子性。
Native SQL prerequisites
CI 的 sql-native job 提供 PostgreSQL 16、MySQL 8.4 和 native psql /
mysql / sqlite3 客户端。本地只能指向可销毁的 loopback 测试服务,不能使用生产库。
- Compiled CLI / 编译后 CLI:
SSHX_E2E_REAL_SQL=1,明确设置测试专用SSHX_E2E_PG_PASSWORD/SSHX_E2E_MYSQL_PASSWORD;fixture 使用127.0.0.1:5432(用户sshx)和127.0.0.1:3306(用户root), 两者数据库均为sshx_e2e。启用后缺客户端或服务应失败,不回退为模拟测试。 - Engine transactions / 引擎事务:
SSHX_SQL_INTEGRATION_POSTGRES=1或SSHX_SQL_INTEGRATION_MYSQL=1,并设置SSHX_SQL_INTEGRATION_{DATABASE,USER,HOST,PORT,PASSWORD}指向独立测试数据库。 SQLite 使用真实本地客户端;其他引擎未显式启用时跳过,不计为通过。
# Only after provisioning disposable services and explicit test credentials:
SSHX_E2E_REAL_SQL=1 go test ./tests/e2e -run '^TestSQLRealEngineReliability$' -count=1
go test ./internal/sqlsafe -run 'TestSQLRealEngine(RollbackAndCommit|ConcurrentWriterExcludedFromPreimage)' -count=1