diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b9cf3b5..83e1572 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,7 +25,7 @@ jobs:
go-version: ${{ matrix.go }}
- name: Cache Go modules
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: |
~/.cache/go-build
@@ -38,12 +38,12 @@ jobs:
run: go mod download
- name: Run tests
- run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
+ run: go test -v -short -race -coverprofile=coverage.out -covermode=atomic ./...
shell: bash
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest'
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v5
with:
files: ./coverage.out
flags: unittests
@@ -52,6 +52,75 @@ jobs:
- name: Build
run: go build -v ./cmd/sshx
+ e2e:
+ name: E2E (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.24"
+
+ - name: Download dependencies
+ run: go mod download
+
+ - name: Prepare isolated macOS keychain
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ keychain_path="$RUNNER_TEMP/sshx-e2e.keychain-db"
+ keychain_password="sshx-e2e-ci"
+ original_keychain="$(security default-keychain -d user | sed 's/^[[:space:]]*\"//; s/\"[[:space:]]*$//')"
+ keychain_list_file="$RUNNER_TEMP/sshx-e2e-original-keychains.txt"
+ security list-keychains -d user | sed 's/^[[:space:]]*\"//; s/\"[[:space:]]*$//' > "$keychain_list_file"
+ original_keychains=()
+ while IFS= read -r item; do
+ if [ -n "$item" ]; then original_keychains+=("$item"); fi
+ done < "$keychain_list_file"
+ security create-keychain -p "$keychain_password" "$keychain_path"
+ security set-keychain-settings -lut 3600 "$keychain_path"
+ security unlock-keychain -p "$keychain_password" "$keychain_path"
+ security list-keychains -d user -s "$keychain_path" "${original_keychains[@]}"
+ security default-keychain -d user -s "$keychain_path"
+ {
+ echo "SSHX_E2E_KEYCHAIN=$keychain_path"
+ echo "SSHX_E2E_ORIGINAL_KEYCHAIN=$original_keychain"
+ echo "SSHX_E2E_ORIGINAL_KEYCHAIN_LIST=$keychain_list_file"
+ } >> "$GITHUB_ENV"
+
+ - name: Run compiled-binary E2E tests
+ env:
+ SSHX_E2E_REAL_KEYRING: ${{ runner.os == 'macOS' && '1' || '0' }}
+ run: go test -v ./tests/e2e
+
+ - name: Remove isolated macOS keychain
+ if: always() && runner.os == 'macOS'
+ shell: bash
+ run: |
+ if [ -n "${SSHX_E2E_ORIGINAL_KEYCHAIN:-}" ]; then
+ security default-keychain -d user -s "$SSHX_E2E_ORIGINAL_KEYCHAIN"
+ fi
+ if [ -f "${SSHX_E2E_ORIGINAL_KEYCHAIN_LIST:-}" ]; then
+ original_keychains=()
+ while IFS= read -r item; do
+ if [ -n "$item" ]; then original_keychains+=("$item"); fi
+ done < "$SSHX_E2E_ORIGINAL_KEYCHAIN_LIST"
+ if [ ${#original_keychains[@]} -gt 0 ]; then
+ security list-keychains -d user -s "${original_keychains[@]}"
+ fi
+ rm -f "$SSHX_E2E_ORIGINAL_KEYCHAIN_LIST"
+ fi
+ if [ -n "${SSHX_E2E_KEYCHAIN:-}" ]; then
+ security delete-keychain "$SSHX_E2E_KEYCHAIN" || true
+ fi
+
lint:
name: Lint
runs-on: ubuntu-latest
diff --git a/AGENT.md b/AGENT.md
index 45a849a..0f5bdb2 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -10,36 +10,45 @@ Module: `github.com/talkincode/sshx` · Language: Go 1.24 · License: MIT
## 1. Mission
-`sshx` is a barrier-free, cross-platform **SSH/SFTP command-line client** with a
-built-in **OS-keyring password manager** and **named host configuration**. It
-exists to make ad-hoc operations across many remote servers fast and safe:
+`sshx` is an **agent-native remote host execution tool over SSH**. SSH/SFTP is
+the trusted transport; execution is the product. It turns an agent's intent
+into a one-shot remote operation whose target and side effects are explicit,
+whose result is machine-decidable, and whose security context is auditable:
-> One command, multiple servers, zero password hassle.
+> SSH is the channel. X is execution.
The core value proposition:
-- Run a command (or transfer a file) on a remote host in a single invocation.
-- Never type or store passwords in plaintext — they live in the OS keyring and
- sudo passwords are auto-filled.
-- Address hosts by a short name instead of full connection details.
-- Be secure by default (strict host-key verification, command safety guardrails).
+- Give agents one stable process contract for target resolution, preview,
+ execution, structured results, failure classification, and audit evidence.
+- Run a command or file action on an existing SSH host in one invocation, with
+ no resident remote agent and no long-running control plane.
+- Reduce decision and retry cost through named hosts, JSON, exit codes,
+ `error_kind`, timeout, and dry-run plans.
+- Protect credentials and trust boundaries through the OS keyring, strict
+ host-key verification, sudo over stdin, explicit bypasses, and audit redaction.
+
+Human operators use the same CLI, preview, safety, and audit semantics to
+supervise agents and troubleshoot operations.
## 2. Goals
-1. **Single self-contained binary** — no runtime dependencies, installable via
+1. **Stable agent execution contract** — predictable stdout/stderr, exit codes,
+ JSON results, failure kinds, previews, and audit semantics.
+2. **Single self-contained binary** — no runtime dependencies, installable via
`go install`, an install script, or a downloaded release artifact.
-2. **Cross-platform parity** — Linux, macOS, and Windows are all first-class.
-3. **Secure by default** — strict `known_hosts` verification, keyring-backed
+3. **Cross-platform parity** — Linux, macOS, and Windows are all first-class.
+4. **Secure by default** — strict `known_hosts` verification, keyring-backed
secrets, sudo password delivered over stdin (never interpolated), and command
safety checks that block obviously destructive operations.
-4. **Low cognitive load** — sensible defaults, named hosts, key-first auth with
+5. **Low agent decision cost** — sensible defaults, named hosts, key-first auth with
password fallback only when an SSH login password is already provided, and
- helpful error messages.
-5. **Multi-server ergonomics** — per-host SSH keys and per-host/per-server
+ classified failures rather than prose-only errors.
+6. **Multi-server ergonomics** — per-host SSH keys and per-host/per-server
password keys so one tool covers a whole fleet.
-6. **Execution preview** — `--dry-run` explains the local execution plan without
+7. **Execution preview** — `--dry-run` explains the local execution plan without
connecting, executing, reading keyring secrets, or mutating state.
-7. **Auditability** — non-dry-run invocations write structured JSONL audit
+8. **Auditability** — non-dry-run invocations write structured JSONL audit
events under `~/.sshx/audit` by default, with secrets and stdout/stderr
excluded.
@@ -55,6 +64,11 @@ the project's mission:
CLI-only. Do not reintroduce an `mcp-stdio` mode or MCP tools.
- ❌ **Daemons / long-running services / connection pools** — every command opens
a connection, does its work, and exits. There is no background process.
+- ❌ **Resident remote agent / control plane** — do not require a service to be
+ installed on managed hosts and do not turn sshx into a fleet control plane.
+- ❌ **Desired-state configuration / workflow orchestration** — bounded fan-out
+ execution is in scope; playbooks, schedulers, reconciliation, and long-lived
+ workflow state are not.
- ❌ **GUI / TUI** — interaction is through flags and stdout/stderr only.
- ❌ **Full OpenSSH replacement** — no interactive login shell multiplexing,
port forwarding / tunneling, SOCKS proxy, X11 forwarding, or agent forwarding.
@@ -63,9 +77,10 @@ the project's mission:
- ❌ **Bespoke config formats** — configuration is `~/.sshx/settings.json`,
environment variables, and CLI flags. Nothing else.
-**In scope (welcome):** command execution, SFTP file ops, password/secret
-management, named host management, authentication UX, safety checks, and
-cross-platform correctness.
+**In scope (welcome):** agent execution contracts, command execution, SFTP file
+actions, bounded multi-host execution, password/secret references, named host
+management, authentication UX, safety checks, auditing, and cross-platform
+correctness.
## 4. Architecture
@@ -245,9 +260,41 @@ verification for it:
- Coverage is tracked (Codecov). Coverage is currently modest; **raising it is an
ongoing goal** — prefer adding tests alongside any change you make.
+### Capability coverage matrix (mandatory)
+
+The source of truth is the acceptance matrix in
+[`docs/roadmap.md`](docs/roadmap.md). These requirements are **MUST-level**:
+
+1. Every top-level product capability MUST have at least one Happy Path E2E.
+2. Every high-risk capability MUST cover at least one failure path.
+3. Every permission-sensitive capability MUST verify at least two roles or
+ permission states.
+4. Every state-changing operation MUST verify recovery or rollback after a
+ failure.
+5. Adding a top-level capability MUST include its E2E and an updated matrix row;
+ otherwise the change is incomplete.
+
+Existing unit, component, or local-server tests do not count as CLI E2E unless
+they exercise the compiled `sshx` process across the documented external
+boundary. Record real evidence paths in the matrix and mark missing coverage as
+a gap rather than inferring it.
+
+Canonical commands:
+
+```bash
+make test-short # unit/component suite without compiled-binary E2E
+make test-e2e # compiled sshx process across real SSH/SFTP protocol boundaries
+```
+
+The E2E source of evidence is `tests/e2e`. Native OS-keyring lifecycle tests are
+opt-in locally and run against an ephemeral macOS Keychain in CI; never enable
+them against a keyring that cannot be safely isolated and cleaned up.
+
## 10. Roadmap
-A living, maintainer-adjustable plan. Items must respect the boundaries in §3.
+A living, maintainer-adjustable plan. The authoritative product profile,
+directions, and acceptance matrix are in [`docs/roadmap.md`](docs/roadmap.md).
+Items must respect the boundaries in §3.
**Now / recently shipped**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 997574e..614f1a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add compiled-binary SSH/SFTP E2E coverage for command execution, structured
+ results, host trust, permissions, partial completion, dry-run, host import,
+ SFTP, server-to-server transfer, keyring-backed sudo, and audit recovery.
+- Run the E2E suite on Linux and macOS CI, including production-binary checks
+ against an ephemeral macOS Keychain.
+
+### Changed
+
+- Upgrade the CI cache and Codecov actions to their supported major versions.
+
+### Fixed
+
+- Make public-key rejection correctly trigger an explicitly configured SSH
+ password fallback; the previous check matched a server-side error type that
+ the SSH client does not return.
+- Keep local audit write failures visible at error log level without changing
+ a successfully completed remote command into a false execution failure.
+
+### Documentation
+
+- Reposition sshx as an agent-native remote host execution tool: SSH is the
+ trusted channel and X is execution. Add the efficiency and security model,
+ hard product boundaries, future directions, and an evidence-based capability
+ coverage matrix with mandatory E2E floors.
+
## [0.0.14] - 2026-07-17
### Added
diff --git a/Makefile b/Makefile
index 709b1e4..eae6198 100644
--- a/Makefile
+++ b/Makefile
@@ -30,7 +30,7 @@ GOMOD=$(GOCMD) mod
GOFMT=$(GOCMD) fmt
GOVET=$(GOCMD) vet
-.PHONY: help build build-all test test-verbose test-coverage clean install uninstall run fmt vet lint deps version
+.PHONY: help build build-all test test-short test-e2e test-verbose test-coverage clean install uninstall run fmt vet lint deps version
version: ## Show the version string used for builds
@echo "$(VERSION)"
@@ -72,6 +72,10 @@ test-short: ## Run unit tests (skip integration tests)
@echo "Running unit tests..."
$(GOTEST) -v -short ./...
+test-e2e: ## Run compiled-binary SSH/SFTP E2E tests (native keyring is opt-in)
+ @echo "Running compiled-binary E2E tests..."
+ $(GOTEST) -v ./tests/e2e
+
test-verbose: ## Run verbose tests
@echo "Running verbose tests..."
$(GOTEST) -v -race ./...
diff --git a/README.md b/README.md
index 74e85c3..03221dd 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
\______/ \______/ \__| \__|\__| \__|
-Secure SSH & SFTP Client with Built-in Password Manager
+Agent-Native Remote Execution over SSH
```
@@ -44,13 +44,15 @@ English | [简体中文](./README_CN.md)
# SSHX
-`sshx` is a barrier-free, cross-platform SSH/SFTP command-line client with a built-in system keyring password manager, making it easy to manage and operate multiple remote servers.
+> **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.
## Why You Need It?
-Managing multiple servers means juggling different passwords and repeatedly entering sudo passwords. `sshx` securely stores passwords in your system keyring and auto-fills sudo passwords, so you can run commands across many servers without the password hassle. One command, multiple servers, zero password hassle.
+Agents do not need another interactive SSH shell. They need a stable, composable remote execution contract with explicit side effects. `sshx` reduces argument assembly through named hosts, removes text guessing through JSON, exit codes, and error kinds, and lowers operational risk through dry-run plans, safety guardrails, the OS keyring, host-key verification, and local auditing.
-**New!** Host Configuration Management - Store your frequently used host configurations in `~/.sshx/settings.json` and connect with just a name instead of typing full connection details every time. Each host can have its own SSH private key. Add hosts interactively!
+It remains a single binary with one-shot invocations and no resident component on remote hosts: **efficient, secure, and auditable remote execution for agents over SSH.** Human operators use the same command, preview, and audit semantics for supervision and troubleshooting.
## Project Structure
@@ -60,13 +62,13 @@ Managing multiple servers means juggling different passwords and repeatedly ente
## Key Features
-1. Cross-platform SSH/SFTP operations (supports sudo auto-fill).
-2. Direct server-to-server file transfer (`--transfer=
: --to=:`), streamed through the local machine without touching local disk.
-3. Password management (Keychain / Secret Service / Credential Manager).
-4. Host configuration management with per-host SSH keys.
-5. Dry-run execution plan preview for humans and agents.
-6. Local structured audit trail with safe default redaction.
-7. Script execution and command security validation.
+1. Agent-friendly JSON, stable exit codes, separated stdout/stderr, and classified failures.
+2. Dry-run execution plans and default-on local structured auditing with safe redaction.
+3. Named host management and selective OpenSSH config import with per-host SSH keys.
+4. Strict host-key verification, destructive-command guardrails, and explicit bypass semantics.
+5. OS-keyring password management and sudo auto-fill over stdin.
+6. Cross-platform SSH/SFTP command and file actions.
+7. Direct server-to-server transfer, streamed through the local machine without touching local disk.
## Installation
@@ -687,9 +689,14 @@ sudo chmod +x /usr/local/bin/sshx
## Development
+The project's target state, hard non-goals, and capability coverage matrix live in the [Project Profile and Direction](docs/roadmap.md). Every new top-level capability must add a Happy Path E2E and update the matrix; high-risk, permission-sensitive, and state-changing capabilities must also meet the corresponding failure, permission-state, and recovery coverage floors.
+
```bash
-# Run tests
-go test ./...
+# Run fast unit/component tests
+make test-short
+
+# Run compiled-binary SSH/SFTP E2E tests
+make test-e2e
# Format code
gofmt -w .
@@ -703,6 +710,9 @@ make lint
> The lint target requires `golangci-lint` v2.6.1 or newer. Install it with `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.1`.
+The normal E2E run uses an isolated, test-only keyring provider. CI additionally
+checks the production binary against an ephemeral macOS Keychain.
+
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
diff --git a/README_CN.md b/README_CN.md
index 9d3fcbf..ff08369 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -11,7 +11,7 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
\______/ \______/ \__| \__|\__| \__|
-内置密码管理器的安全 SSH 和 SFTP 客户端
+面向 Agent 的远程主机执行工具
```
@@ -44,11 +44,15 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
# SSHX
-`sshx` 是一个无障碍、跨平台的 SSH/SFTP 命令行客户端,内置基于系统密钥链的密码管理器,让你轻松管理和操作多台远程服务器。
+> **SSH 是通道,X 代表执行。**
+
+`sshx` 是一个**面向 Agent 的远程主机执行工具**。它通过 SSH/SFTP 连接现有主机,把目标解析、执行预览、安全检查、命令与文件动作、结构化结果和审计留痕收敛到一次 CLI 调用中。
## 为什么你需要它?
-管理多台服务器时,记住不同的密码、反复输入 sudo 密码都很繁琐。`sshx` 将密码安全存储在系统密钥链中,自动填充 sudo 密码,让你在多台服务器上执行命令时不再为密码烦恼。一个命令,多台服务器,零密码困扰。
+Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组合、能解释副作用的远程执行契约。`sshx` 用命名主机减少参数拼装,用 JSON、退出码和错误分类减少文本猜测,用 dry-run、安全护栏、系统密钥链、host-key 校验和本地审计降低误操作与凭据风险。
+
+它保持单二进制、单次调用、无远端驻留组件:**让 Agent 通过 SSH,高效、安全、可审计地在远程主机上完成任务。** 人类运维者也使用同一套命令、预览与审计语义进行监督和排障。
## 项目结构
@@ -58,13 +62,13 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
## 核心特性
-1. 跨平台 SSH/SFTP 操作(支持 sudo 自动填充)。
-2. 服务器到服务器直接文件传输(`--transfer=
: --to=:`),数据经本机中转流式传输,不落本地磁盘。
-3. 密码管理(Keychain / Secret Service / Credential Manager)。
-4. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。
-5. 面向人和 agent 的 dry-run 执行计划预览。
-6. 本地结构化审计日志,并默认做安全脱敏。
-7. 脚本执行和命令安全验证。
+1. Agent 友好的 JSON、稳定退出码、stdout/stderr 分离和错误分类。
+2. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。
+3. 命名主机管理和 OpenSSH config 选择性导入,支持每台主机独立 SSH key。
+4. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。
+5. 系统密钥链密码管理和通过 stdin 完成的 sudo 自动填充。
+6. 跨平台 SSH/SFTP 命令与文件动作。
+7. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。
## 安装
@@ -603,9 +607,14 @@ sudo chmod +x /usr/local/bin/sshx
## 开发
+项目的目标状态、非目标铁律和业务能力覆盖矩阵维护在[项目画像与方向](docs/roadmap.md)。新增一级业务能力必须同步增加 Happy Path E2E 并更新矩阵;高风险、权限和状态修改能力还必须满足相应失败、权限差异与恢复覆盖底线。
+
```bash
-# 运行测试
-go test ./...
+# 运行快速单元/组件测试
+make test-short
+
+# 运行编译后二进制 SSH/SFTP E2E
+make test-e2e
# 格式化代码
gofmt -w .
@@ -619,6 +628,8 @@ make lint
> lint 目标需要 `golangci-lint` v2.6.1 或更高版本。使用 `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.1` 安装。
+常规 E2E 使用仅供测试的隔离 keyring 后端;CI 还会让生产构建连接临时 macOS Keychain,验证真实系统 keyring 生命周期。
+
## 许可证
本项目采用 MIT 许可证 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 3f3d041..d1f50a1 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -16,3 +16,4 @@
- [使用场景](zh/usage-scenarios.md)
- [安全准则](zh/security-guidelines.md)
- [故障排查](zh/troubleshooting.md)
+- [Project Profile / 项目画像与方向](roadmap.md)
diff --git a/docs/index.md b/docs/index.md
index 1c179b3..033cec2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,6 +1,10 @@
# SSHX Documentation
-`sshx` is a cross-platform SSH and SFTP command-line client for people and automation agents who operate many remote servers. It keeps the tool shape simple: one command opens one SSH session, does the requested work, writes an optional local audit event, and exits.
+> **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.
@@ -16,22 +20,22 @@ The documentation starts in English by default. Use the language switch in the t
## Mental Model
-Think of `sshx` as a safer one-shot remote operation helper, not a shell replacement and not a remote orchestration platform.
+Think of `sshx` as a remote execution primitive in an agent's toolbox, not an interactive shell replacement and not a desired-state or workflow orchestration platform.
```text
-human, script, or agent
+agent, automation, or human operator
|
v
-sshx CLI flags and optional .env
+agent contract: CLI / JSON / exit code / dry-run
|
v
-named host resolution and safety checks
+X execution: target / safety / action / audit
|
v
-SSH command or SFTP action
+SSH channel: auth / host key / SSH exec / SFTP
|
v
-structured result, exit code, optional audit event
+remote host
```
## Common First Commands
@@ -67,6 +71,7 @@ Read [Security Guidelines](security-guidelines.md) before using `sshx` in produc
## Where To Go Next
+- [Project Profile and Direction](roadmap.md) defines the product position, hard non-goals, and acceptance matrix (currently maintained in Chinese).
- [Getting Started](getting-started.md) gets one host working.
- [Host Management](host-management.md) explains named hosts and key selection.
- [Usage Scenarios](usage-scenarios.md) gives practical examples for daily operations.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index e02c742..8ae6f16 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,180 +1,222 @@
# sshx 项目画像与方向
-## 项目概述
-
-`sshx` 是一个单二进制、跨平台的 SSH/SFTP 命令行工具,面向需要频繁操作多台远程服务器的人和自动化 agent。它的核心价值是:一次命令完成远程执行或文件操作,密码和 sudo 凭据交给系统密钥链保管,常用服务器可用短名称访问。
-
-项目的运行方式保持简单:每次调用读取 CLI 参数、环境变量和可选的主机配置,建立一条直连 SSH 会话,完成命令或 SFTP 操作后退出。它不是后台服务,也不维护长期连接。
+> **SSH is the channel. X is execution.**
+>
+> **SSH 是通道,X 代表执行。**
-```text
-用户 / 脚本 / AI agent
- |
- v
-cmd/sshx/main.go
- |
- v
-internal/app
- - 参数解析、命令分发
- - 主机配置管理 ~/.sshx/settings.json
- - 密码管理与 keyring 交互
- |
- v
-internal/sshclient
- - SSH 连接与认证
- - host-key 校验
- - 远程命令执行 / SFTP
- - sudo stdin 注入与危险命令拦截
- |
- +--------------------+
- | |
- v v
-远程 SSH/SFTP 服务 OS keyring / known_hosts
-```
+## 项目定位
-## 项目画像(目标状态)
-
-`sshx` 做好之后,应当是一个可以被人类终端和自动化系统共同信任的远程操作工具:人类使用时命令短、反馈清楚、默认安全;agent 使用时输出稳定、退出码可判断、失败原因可机读;事后排查时能追溯关键操作的来源、目标、结果和安全上下文。
-
-项目优先级是:安全默认值、数据正确性和可审计溯源优先于便利性;清晰的单次调用语义优先于复杂的长期会话能力;跨平台一致性优先于某个平台的深度特性。任何新能力都应服务于“一次调用、明确结果、低认知负担、事后可解释”的体验。
-
-安全设计不是附加功能,而是产品边界。默认行为应保护 host-key、凭据和 sudo 密码路径;绕过安全检查必须显式、可见,并且不应让用户误以为 `sshx` 是不可信命令的沙箱。审计溯源也应遵守同一边界:记录足够解释操作链路的元数据,但不把 secret、sudo 密码、私钥内容或高敏 stdout/stderr 写进日志。
-
-## 当前能力清单
+`sshx` 是一个**面向 Agent 的远程主机执行工具**。它以 SSH/SFTP 作为已有、成熟、普遍可达的可信通道,把 Agent 的执行意图转换为一次边界清楚、结果可判断、过程可审计的远程主机操作。
-- SSH 命令执行
+这一定义刻意把产品重心从“SSH 客户端”移到“远程执行”上:
- 支持 `sshx -h= [options] `,默认直连远程主机并执行一次命令;远程命令退出码会透传为 `sshx` 自身退出码。证据:`cmd/sshx/main.go`、`internal/app/app.go`、`internal/sshclient/client.go`、`internal/app/usage.go`。
+- **SSH 是通道**:负责连接、认证、加密、主机身份校验和文件传输,但不是 sshx 的全部产品价值。
+- **X 是执行**:负责目标解析、执行预览、安全检查、命令或文件动作、结果结构化、失败分类和审计留痕。
+- **Agent 是首要调用者**:CLI 不是纯人类交互界面,而是一份稳定的进程级工具契约;人类运维者与 Agent 共用同一套目标、安全和审计语义。
-- Agent / 脚本模式
+一句话定位:
- `--json` 会在 stdout 输出单个结构化 JSON 对象,包含 `exit_code`、`success`、`stdout`、`stderr`、`duration_ms`、`auth_method`、`error_kind` 等字段;stderr 仍用于诊断信息。证据:`internal/app/app.go`、`internal/app/usage.go`、`skills/sshx/SKILL.md`。
+> **让 Agent 通过 SSH,高效、安全、可审计地在远程主机上完成任务。**
-- 命令超时与 PTY 选择
+英文定位:
- `--timeout` 可限制远程命令运行时间;默认不使用 PTY,以保持 stdout/stderr 分离,`--pty` 仅在需要终端语义时显式启用,且不能与 `--json` 组合。证据:`internal/app/config.go`、`internal/app/app.go`、`internal/sshclient/client.go`。
+> **Agent-native remote execution over SSH.**
-- SFTP 文件操作
+## 项目概述
- 支持上传、下载、列目录、创建目录、删除远程路径等单次 SFTP 操作。证据:`internal/app/config.go`、`internal/sshclient/client.go`、`internal/app/usage.go`。
+Agent 操作远程主机时,真正的困难通常不在“能否建立 SSH 连接”,而在于如何稳定地回答以下问题:目标是哪台主机、使用什么身份、将执行什么、是否越过安全边界、执行是否成功、失败发生在哪一层、事后能否解释这次操作。`sshx` 应把这些重复且高风险的细节收敛为一个短命令和一份稳定结果。
-- 系统密钥链密码管理
+项目保持单二进制、跨平台、无远端驻留组件的形态。每次调用解析目标和约束,建立 SSH/SFTP 连接,执行一个明确动作,返回结果并退出;本地配置、系统 keyring、`known_hosts` 和审计记录共同构成执行所需的最小信任环境。
- 密码存储在系统 keyring 服务 `sshx` 下,支持设置、检查、获取、删除和常见 key 探测;交互式终端上 `--password-get` 不直接打印明文密码,只有管道或重定向时输出原始值。证据:`internal/app/password.go`、`internal/sshclient/validate.go`、`CHANGELOG.md`。
+```text
+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 / known_hosts / audit JSONL
+```
-- 命名主机配置
+## 项目画像(目标状态)
- `~/.sshx/settings.json` 保存主机短名称、地址、端口、用户、描述、系统类型、每主机 SSH key 和密码 key;支持添加、更新、列表、连接测试、全量连接测试和删除。配置写入采用临时文件加 rename 的方式,并设置为 `0600`。证据:`internal/app/settings.go`、`internal/app/host_manager.go`、`internal/app/usage.go`。
+`sshx` 做好之后,应成为 Agent 工具箱里的“远程执行基本件”:像调用本地进程一样容易组合,又明确承认远程操作具有凭据、权限、网络和破坏性副作用。Agent 不需要模拟交互式终端,不需要从自然语言日志猜测结果,也不需要在每个任务中重新拼装 SSH 参数、sudo 注入、安全检查和审计逻辑。
-- 认证路径
+### 效率画像
- 默认先尝试 SSH key;只有调用方已提供 SSH 登录密码时,服务端拒绝 key 后才会回退到密码认证。`--no-key` / `--password-only` 可强制密码模式;命名主机的 `password_key` 主要用于 sudo 自动填充。证据:`internal/sshclient/client.go`、`internal/app/host_manager.go`、`skills/sshx/SKILL.md`。
+效率不是单纯缩短 SSH 握手时间,而是减少 Agent 完成一次可靠远程操作所需的决策、调用和返工:
-- host-key 校验
+- **意图表达短**:命名主机和可复用配置把地址、端口、用户、key 与 sudo key 收敛到稳定目标名。
+- **一次调用闭环**:发现或解析目标、执行动作、返回结构化结果、形成审计记录,不要求 Agent 维持交互会话。
+- **机器判断直接**:stdout、stderr、退出码、`success` 与 `error_kind` 各自职责稳定,Agent 不依赖脆弱文本匹配。
+- **执行前少返工**:dry-run 能在连接、读取 secret 或修改状态前暴露目标解析、sudo、安全绕过和副作用意图。
+- **失败后快恢复**:错误能区分配置、连接、认证、host-key、超时、命令退出和安全阻断,避免盲目重试。
+- **数据移动少落地**:远端到远端传输可以流式中转,不要求先写入本地磁盘。
- 默认使用 `known_hosts` 严格校验主机 key;未知主机或变更 key 会阻止连接。`--accept-unknown-host` 和 `--insecure-hostkey` 是显式 opt-in。证据:`internal/sshclient/client.go`、`AGENT.md`。
+### 安全画像
-- sudo 密码自动填充
+安全不是一句“危险命令检测”,而是一组贯穿执行生命周期的边界:
- 识别以 `sudo` 开始的命令后,从 keyring 读取密码,并通过 stdin 传入 `sudo -S -p ''`,不把密码拼进命令字符串。证据:`internal/sshclient/client.go`、`internal/sshclient/validate.go`。
+- **凭据边界**:secret 默认进入 OS keyring,不进入配置、命令字符串、审计记录或普通终端回显。
+- **通道边界**:默认严格校验 host key,未知或变更的远端身份不会被静默接受。
+- **权限边界**:SSH 登录身份与 sudo secret 语义分离;特权执行必须可识别、可预览、可追溯。
+- **动作边界**:明显破坏性操作默认阻断;绕过必须显式且进入结果与审计上下文。
+- **副作用边界**:Agent 在执行前能知道动作是否会连接、读取 secret、修改本地状态或修改远端状态。
+- **责任边界**:记录足够解释“谁通过什么入口,对哪台主机,以何种安全上下文做了什么,结果如何”,同时默认不持久化敏感输出。
-- 危险命令防护
+安全、正确性与可审计性高于便利和吞吐;在发生冲突时,宁可要求调用者显式表达意图,也不静默降级信任边界。效率优化必须减少无意义摩擦,但不能用模糊目标、隐式凭据选择或不可解释的并发换速度。
- 默认拦截一组明显破坏性命令,例如删除根目录、格式化磁盘、fork bomb、`curl | sh`、关机重启和关键系统文件覆盖;`--force` 或 `--no-safety-check` 可显式绕过。证据:`internal/sshclient/validate.go`、`internal/app/app.go`、`internal/app/usage.go`。
+### 产品边界画像
-- 构建、发布和质量守护
+`sshx` 是执行工具,不是远程主机上的 Agent,也不是持续运行的控制平面。它应在现有 SSH 基础设施上提供一个清晰的 Agent 执行契约,而不是要求每台服务器安装新服务。它可以支持受控的批量执行和更强的执行描述,但不承担期望状态管理、工作流编排、资产治理或组织级审批系统的全部职责。
- Makefile 提供构建、测试、覆盖率、lint、跨平台编译和安装目标;CI 在 Ubuntu 和 macOS 上使用 Go 1.24 运行测试、race、覆盖率、lint 和安全扫描;release workflow 在 tag push 时构建 Linux、macOS、Windows 产物并生成 checksums,并在配置 `HOMEBREW_TAP_TOKEN` 后自动渲染、推送 Homebrew tap formula(`talkincode/homebrew-tap`)。证据:`Makefile`、`.github/workflows/ci.yml`、`.github/workflows/release.yml`、`RELEASE.md`。
+## 当前能力清单
-## 非目标(铁律)
+- **单次远程命令执行**
-- 不重新引入 MCP server、`mcp-stdio` 模式或 MCP tools。`sshx` 的产品形态是 CLI。
+ 支持 `sshx -h= [options] `,默认不启用 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 结构化结果契约**
-- 不做 GUI 或 TUI。交互面保持在 flags、stdout、stderr 和机器可读 JSON。
+ `--json` 输出单个 JSON 对象,包含成功状态、退出码、输出、耗时、认证方式和 `error_kind`;sshx 自身失败与远程命令失败可以区分。证据:`internal/app/app.go`、`internal/app/agentmode_test.go`、`internal/app/usage.go`。
-- 不做完整 OpenSSH 替代品。不覆盖交互式登录 shell 复用、端口转发、隧道、SOCKS proxy、X11 forwarding 或 agent forwarding。
+- **执行计划预览**
-- 不提供明文 secret 存储。凭据默认只进入 OS keyring;inline password 只能作为高风险便利入口存在,并应持续被明确提示。
+ `--dry-run` 在建立连接、执行动作、读取 keyring secret、更新 `known_hosts` 或写配置前生成本地执行计划;可与 `--json` 组合供 Agent 判断。证据:`internal/app/dryrun.go`、`internal/app/agentmode_test.go`、`internal/app/transfer_test.go`。
-- 不扩展出新的私有配置格式。配置边界是 CLI flags、环境变量、`.env` 和 `~/.sshx/settings.json`。
+- **命名主机发现与管理**
-- 不把危险命令防护宣传成安全沙箱。它只能拦截常见误操作,不能承诺执行不可信命令是安全的。
+ `~/.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`。
-- 不把审计能力做成企业 SIEM、集中式合规平台或不可篡改账本。`sshx` 可以提供结构化溯源材料,但不承担组织级审计系统的全部职责。
+- **SFTP 与主机间文件执行动作**
-- 不为了局部平台特性牺牲 Linux、macOS、Windows 的一等支持。
+ 支持上传、下载、列表、建目录、删除,以及两台远端主机之间经本机流式中转的文件或目录传输。证据:`internal/sshclient/client.go`、`internal/sshclient/transfer.go`、`internal/app/transfer.go`、`internal/app/transfer_test.go`。
-## 方向与意图
+- **凭据与认证边界**
-- 提升核心路径可信度
+ 密码存放在系统 keyring;默认优先 SSH key,只有显式提供 SSH 登录密码时才回退密码认证;命名主机可独立选择 SSH key 和 sudo password key。证据:`internal/app/password.go`、`internal/sshclient/client.go`、`internal/sshclient/client_test.go`。
- 命令执行、认证回退、host-key 校验、sudo stdin、settings 原子写入、JSON 契约和 SFTP 操作是项目的负重路径。未来改动应让这些路径更容易被测试守护、更容易定位失败,而不是扩大不受控行为面。
+- **通道信任与动作护栏**
-- 改善命名主机的规模化体验
+ 默认通过 `known_hosts` 严格校验 host key;未知主机接受和不安全校验必须显式开启。明显破坏性命令默认被拦截,`--force` / `--no-safety-check` 是显式绕过。证据:`internal/sshclient/client.go`、`internal/sshclient/validate.go`、`internal/sshclient/client_test.go`、`internal/sshclient/validate_test.go`。
- 当用户管理的主机数量增加时,`--host-list`、连接测试和配置编辑需要更易扫描、更少出错。标签、分组、丰富列表输出或更顺手的编辑体验都属于这个方向,但必须保持 `settings.json` 简单、可审阅、可迁移。
+- **本地结构化审计**
-- 让密码 key 发现和命名更一致
+ 非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:`internal/app/audit.go`、`internal/app/audit_test.go`。
- 现有 keyring API 限制导致 `--password-list` 只能探测常见 key。未来方向是减少“密码存在但用户不知道 key 名”的摩擦,同时不引入明文索引、不泄露基础设施命名、不破坏系统 keyring 作为信任根的边界。
+- **跨平台交付**
-- 强化 agent 友好契约
+ 项目以单二进制形式面向 Linux、macOS 和 Windows,支持 Go 安装、安装脚本、Release 产物和 Homebrew tap。证据:`Makefile`、`.github/workflows/ci.yml`、`.github/workflows/release.yml`、`install.sh`、`install.ps1`。
- `--json`、退出码、`error_kind`、stdout/stderr 分离和 timeout 是 agent 使用的核心契约。后续能力应尽量让程序可以分支处理失败,而不是解析自然语言日志。
+## 非目标(铁律)
-- 扩展 SFTP 的实用范围
+- **不把 SSH 本身重新实现一遍。** 不追求交互式 shell 复用、通用端口转发、SOCKS、X11 或 agent forwarding;SSH 是底层通道,不是功能竞赛对象。
- 递归上传/下载、glob 等能力可以提升文件操作效率。扩展时应保持“一次调用完成一个明确操作”的语义,不把 SFTP 做成长期交互式文件管理器。
+- **不在远端安装驻留 Agent。** 不引入守护进程、后台服务、连接池或常驻控制面;一次调用建立连接、执行、返回并退出。
-- 支持多主机 fan-out 操作
+- **不成为 Ansible、Salt 或工作流引擎。** 可以提供有界的多主机执行,但不引入期望状态语言、playbook 生态、调度系统或长期任务编排。
- 在不引入 daemon 或连接池的前提下,允许对多个命名主机执行同一类检查或命令,并输出聚合报告。这是 `--host-test-all` 思路的自然延伸,目标是 fleet 级可观察结果,而不是长期编排系统。
+- **不在核心二进制内重新引入 MCP server。** CLI 和进程级结构化契约是稳定集成面;需要 MCP 或其他协议时,应由外部适配层调用 sshx,而不是扩张核心运行模型。
-- 支持受控的跳板访问
+- **不把危险命令防护宣传成沙箱。** sshx 降低误操作和凭据泄露风险,但不承诺安全执行恶意或不可信命令。
- 对私有网络主机,ProxyJump 风格能力可以降低运维摩擦。该方向必须保持 host-key 校验、认证路径和错误报告清晰,不能演变成通用隧道或代理产品。
+- **不成为 CMDB、企业 secret vault 或 SIEM。** sshx 可消费主机配置、使用本地 secret backend、生成审计证据,但不替代组织级资产、密钥和合规平台。
-- 建立可审计溯源能力
+- **不提供明文 secret 存储,也不静默放松 host-key 校验。** 便利性不能突破凭据与通道信任边界。
- 对执行过的操作形成结构化溯源材料,应成为重点方向。记录对象应优先覆盖时间、调用入口、人类/脚本/agent 可识别来源、目标主机、解析后的命名主机、远程用户、认证方式、host-key 决策、命令或 SFTP 意图、是否使用 sudo、是否绕过安全检查、退出状态、错误类别和耗时。审计应默认不记录 secret,不捕获敏感 stdout/stderr,且不改变一次调用即退出的模型。
+- **不做 GUI/TUI。** 核心交互面保持为 flags、stdin、stdout、stderr、退出码和结构化文件;图形化体验属于外部工具。
-- 让审计材料可被本地排查和自动化系统消费
+- **不为了局部平台能力牺牲跨平台一等支持。** Linux、macOS 和 Windows 的核心执行契约必须保持一致。
- 审计结果应兼顾人读和机器处理,能够与 `--json` 契约、退出码和 `error_kind` 对齐。未来能力应让用户可以回答“这次操作从哪里来、实际打到了哪台机器、用了什么凭据路径、为什么失败或成功”,而不是只能翻自然语言终端输出。
+## 方向与意图
-- 允许 secret backend 演进但不放松信任边界
+- **把“执行单元”变成稳定产品契约**
- 如果未来支持可插拔 secret backend,默认仍应是 OS keyring,并且所有 backend 都必须遵守“不落明文、不经命令字符串传 sudo 密码、不静默降级”的原则。
+ 每次执行都应能明确表达目标、动作、约束、副作用、安全上下文和结果。无论动作是命令、文件操作还是未来的批量执行,Agent 都能用同一套心智模型预览、执行、判断和审计,而不需要理解内部 SSH 细节。
-## 完成的样子
+- **降低复杂命令与脚本的传递损耗**
-`sshx` 的路线图不是以功能数量衡量,而是以远程操作是否更可靠、更可判断、更不容易误伤来衡量。当人和 agent 都能在多数日常服务器操作中用一次命令得到明确结果,并且安全边界没有被便利性侵蚀,项目方向才算成立。
+ 远程命令经本地 shell、参数解析和远端 shell 多层解释时容易发生引用、通配和变量展开损坏。sshx 应让 Agent 能可靠传递复杂执行内容,并保持“实际执行内容”在 dry-run、审计和结果中的语义一致;具体输入形态由实现阶段选择。
-- 核心执行契约稳定
+- **建立有界的多主机执行能力**
- 人类模式下输出清楚,agent 模式下 stdout 可直接解析;远程命令失败和 `sshx` 自身失败可以稳定区分;timeout、认证失败、host-key 失败、危险命令阻断都有可观察、可分支的结果。
+ Agent 应能对主机集合执行同一检查或动作,并得到逐主机、可聚合、可部分失败的结构化结果。并发必须有界,目标集合必须可预览,失败不能被总成功状态吞掉;该方向服务于执行效率,但不演变成持续编排平台。
-- 凭据和 host-key 路径没有绕路
+- **从命令黑名单走向可解释的执行治理**
- 密码仍由系统 keyring 管理,sudo 密码仍只通过 stdin 传递,host-key 默认严格校验;任何绕过都必须是显式选择,且用户能从命令或输出上看出来。
+ 在现有危险命令防护之外,逐步增强动作分类、只读与变更意图表达、安全绕过原因、调用来源或 run ID 等上下文,使人类审批层或上层 Agent 能基于明确证据决策。治理信息不得伪装成绝对安全保证。
-- 主机配置适合小团队和个人长期维护
+- **强化目标与身份的可发现性**
- `settings.json` 可读、权限正确、写入安全;主机越多时,用户仍能快速知道每个主机使用的地址、用户、key、password key 和连接状态。
+ 主机导入、列表、分组、标签、连接健康和凭据引用应让 Agent 快速找到正确目标,同时避免把基础设施秘密复制到更多位置。规模化体验仍以简单、可审阅、可迁移的本地配置为底线。
-- 自动化使用不会依赖脆弱文本解析
+- **提升失败恢复效率**
- 重要状态应通过退出码、JSON 字段或稳定结构表达。自由文本日志可以辅助人读,但不能成为 agent 判断成败的唯一依据。
+ 连接、认证、host-key、权限、超时、远程退出、部分传输和本地持久化失败应拥有稳定分类与足够上下文。对于会修改状态的动作,结果需要帮助调用者判断“未开始、部分完成、已完成但回执异常”,减少危险重试。
-- 关键操作可审计、可回看、可解释
+- **扩展文件与受控网络边界执行**
- 对远程命令、SFTP 操作、host-key 信任变更、安全检查绕过和配置变更,应能形成结构化记录或等价的溯源材料。记录能支持本地排查和自动化汇总,同时不泄露 secret,不默认持久化高敏命令输出。
+ 继续完善递归文件操作、传输完整性和失败恢复;在需要进入私有网络时,可考虑受控的 jump-host 能力,但不得扩展成通用隧道产品,也不得模糊每一跳的 host-key 与认证决策。
-- 新能力没有突破 CLI-only 边界
+- **保持 secret backend 可演进**
- 即使支持更多主机、更强 SFTP 或跳板访问,项目仍保持单二进制、单次调用、无后台服务、无 GUI/TUI、无 MCP 的形态。
+ 默认信任根仍是 OS keyring。未来若接入其他 secret backend,必须保持 secret 不落明文、不进入命令字符串、不静默降级、用途可区分,并让 Agent 只引用凭据而非读取凭据。
-- 质量守护跟得上风险
+## 完成的样子
- 安全相关逻辑、认证分支、配置写入、JSON 契约、SFTP 行为和跨平台差异应有自动化检查守护。具体测试形式可按代码实际选择,但关键回归应能在本地 `make check` 或 CI 中被挡下。
+`sshx` 的成功不以支持多少 SSH flag 衡量,而以 Agent 能否用更少步骤完成一次可信远程执行衡量。
+
+- Agent 用一个稳定目标名和一个明确动作即可发起执行,不需要重复处理地址、端口、用户、key、sudo secret 与 host-key 细节。
+- dry-run 所展示的目标、动作、安全绕过和副作用,与真实执行及审计记录保持同一语义。
+- 人类输出清楚,机器输出稳定;所有一级失败都有可分支的类别,远程命令失败不会与 sshx 自身失败混淆。
+- 明显危险动作默认受阻,特权执行与安全绕过显式可见;secret 不出现在普通配置、命令拼接、审计记录或默认终端回显中。
+- 多主机执行即使部分失败,也能逐主机说明状态,并避免不受控并发和盲目重试。
+- 会修改远端状态的操作能够说明是否执行、是否部分完成以及下一步如何安全判断,而不是只返回一个模糊 EOF 或通用错误。
+- 项目继续保持单二进制、无远端驻留组件、无核心 MCP server、无长期控制面的轻量边界。
+- 每项一级能力都有覆盖真实 CLI 与真实 SSH/SFTP 边界的验收证据;安全与状态修改路径同时覆盖失败和恢复语义。
+
+## 验收矩阵(业务能力覆盖矩阵)
+
+> 覆盖底线(硬性规定):
+>
+> 1. 每个一级功能至少有一条 Happy Path E2E。
+> 2. 每个高风险功能至少覆盖一条失败路径。
+> 3. 每个涉及权限的功能至少验证两种角色或权限状态。
+> 4. 每个会修改系统状态的操作至少验证一次失败后的恢复或回滚。
+> 5. 每次新增一级业务功能,必须同步新增对应 E2E 并更新本矩阵。
+
+当前仓库已建立 `tests/e2e` 编译后二进制验收套件:测试进程通过真实 TCP SSH/SFTP 协议连接隔离服务端,并从进程退出码、stdout/stderr、JSON、远端文件/状态、`known_hosts`、settings、keyring 和审计 JSONL 观察结果。默认 keyring 场景使用仅在 `sshx_e2e` 构建标签下启用的隔离后端;macOS CI 还会创建临时系统 Keychain,验证生产二进制跨真实 OS keyring 的完整生命周期。组件测试不计作 CLI E2E,表内证据按实际边界标注。
+
+| 一级功能 | 风险 | 权限 | 修改状态 | 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` |
+| 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` |
+| 有界多主机执行(方向) | 高 | 是 | 可能,多主机 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | `--host-test-all` 仅覆盖连接测试,不等同批量执行 |
+| 可解释执行治理(方向) | 高 | 是 | 可能 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | 现有 `--dry-run`、安全检查与审计是基础,不构成完整能力 |
+
+当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。
diff --git a/docs/zh/index.md b/docs/zh/index.md
index 3875238..1071ae4 100644
--- a/docs/zh/index.md
+++ b/docs/zh/index.md
@@ -1,6 +1,10 @@
# SSHX 文档
-`sshx` 是一个跨平台的 SSH/SFTP 命令行客户端,面向经常操作多台远程服务器的人和自动化 agent。它保持一个很简单的模型:一次命令建立一次 SSH 会话,完成指定操作,按需写入本地审计事件,然后退出。
+> **SSH 是通道,X 代表执行。**
+
+`sshx` 是一个面向 Agent 的远程主机执行工具。它通过 SSH/SFTP 连接现有主机,把目标解析、执行预览、安全检查、命令与文件动作、结构化结果和审计留痕收敛到一次 CLI 调用中。
+
+它保持一个简单的模型:一次命令建立一次连接,完成一个明确动作,返回可判断的结果,写入本地审计事件,然后退出;无需在远端安装常驻 Agent,也不引入长期控制面。
文档默认首页是英文。可以使用顶部导航栏里的语言切换入口打开对应中文页面。
@@ -16,22 +20,22 @@
## 心智模型
-把 `sshx` 理解成一个更安全的一次性远程操作助手,而不是交互式 shell 的替代品,也不是远程编排平台。
+把 `sshx` 理解成 Agent 工具箱里的远程执行基本件,而不是交互式 shell 的替代品,也不是期望状态或工作流编排平台。
```text
-人类、脚本或 agent
+Agent、自动化或人类运维者
|
v
-sshx CLI 参数与可选 .env
+Agent 契约:CLI / JSON / 退出码 / dry-run
|
v
-命名主机解析与安全检查
+X 执行:目标解析 / 安全检查 / 动作 / 审计
|
v
-SSH 命令或 SFTP 操作
+SSH 通道:认证 / host-key / SSH exec / SFTP
|
v
-结构化结果、退出码、可选审计事件
+远程主机
```
## 最常用的第一组命令
@@ -67,6 +71,7 @@ sshx -h=prod-web --json "systemctl is-active nginx"
## 下一步
+- [项目画像与方向](../roadmap.md)定义产品定位、非目标铁律和验收矩阵。
- [快速开始](getting-started.md)帮助你让第一台主机跑通。
- [主机管理](host-management.md)说明命名主机和密钥选择。
- [使用场景](usage-scenarios.md)提供大量日常运维例子。
diff --git a/internal/app/app.go b/internal/app/app.go
index 0f2ac1d..8977abf 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -74,7 +74,7 @@ func Run(args []string) (err error) {
audit := newAuditRecorder(config)
defer func() {
if auditErr := audit.finish(config, err); auditErr != nil {
- logger.GetLogger().Warning("failed to write audit event: %v", auditErr)
+ logger.GetLogger().Error("failed to write audit event: %v", auditErr)
}
}()
diff --git a/internal/app/password.go b/internal/app/password.go
index a294e10..cc4694c 100644
--- a/internal/app/password.go
+++ b/internal/app/password.go
@@ -2,6 +2,7 @@ package app
import (
"bufio"
+ "errors"
"fmt"
"io"
"os"
@@ -10,8 +11,7 @@ import (
"golang.org/x/term"
- "github.com/zalando/go-keyring"
-
+ "github.com/talkincode/sshx/internal/keyringstore"
"github.com/talkincode/sshx/internal/sshclient"
"github.com/talkincode/sshx/pkg/logger"
)
@@ -47,7 +47,7 @@ func setPassword(serviceName, key, value string) error {
value = password
}
- if err := keyring.Set(serviceName, key, value); err != nil {
+ if err := keyringstore.Set(serviceName, key, value); err != nil {
return fmt.Errorf("failed to set password: %w", err)
}
@@ -72,9 +72,9 @@ func getPassword(serviceName, key string) error {
return fmt.Errorf("password key is required")
}
- password, err := keyring.Get(serviceName, key)
+ password, err := keyringstore.Get(serviceName, key)
if err != nil {
- if err == keyring.ErrNotFound {
+ if errors.Is(err, keyringstore.ErrNotFound) {
return fmt.Errorf("password not found for key: %s", key)
}
return fmt.Errorf("failed to get password: %w", err)
@@ -104,16 +104,16 @@ func deletePassword(serviceName, key string) error {
return fmt.Errorf("password key is required")
}
- _, err := keyring.Get(serviceName, key)
+ _, err := keyringstore.Get(serviceName, key)
if err != nil {
- if err == keyring.ErrNotFound {
+ if errors.Is(err, keyringstore.ErrNotFound) {
logger.GetLogger().Warning("Password not found for key: %s (already deleted or never existed)", key)
return nil
}
return fmt.Errorf("failed to check password: %w", err)
}
- if err := keyring.Delete(serviceName, key); err != nil {
+ if err := keyringstore.Delete(serviceName, key); err != nil {
return fmt.Errorf("failed to delete password: %w", err)
}
@@ -129,7 +129,7 @@ func checkPassword(serviceName, key string) error {
return fmt.Errorf("password key is required")
}
- _, err := keyring.Get(serviceName, key)
+ _, err := keyringstore.Get(serviceName, key)
if err == nil {
logger.GetLogger().Success("Password exists for key: %s", key)
fmt.Printf("\nKey '%s' is stored in system keyring\n", key)
@@ -137,7 +137,7 @@ func checkPassword(serviceName, key string) error {
return nil
}
- if err == keyring.ErrNotFound {
+ if errors.Is(err, keyringstore.ErrNotFound) {
logger.GetLogger().Warning("Password not found for key: %s", key)
fmt.Printf("\nKey '%s' is NOT stored in system keyring\n", key)
fmt.Printf("Use 'sshx --password-set=%s' to add it\n", key)
@@ -163,12 +163,12 @@ func listPasswords() error {
fmt.Println("Common keys:")
found := false
for _, key := range commonKeys {
- _, err := keyring.Get(sshclient.KeyringServiceName, key)
- switch err {
- case nil:
+ _, err := keyringstore.Get(sshclient.KeyringServiceName, key)
+ switch {
+ case err == nil:
fmt.Printf(" ✓ %s (exists)\n", key)
found = true
- case keyring.ErrNotFound:
+ case errors.Is(err, keyringstore.ErrNotFound):
fmt.Printf(" %s (not set)\n", key)
default:
fmt.Printf(" ? %s (error: %v)\n", key, err)
diff --git a/internal/keyringstore/backend_e2e.go b/internal/keyringstore/backend_e2e.go
new file mode 100644
index 0000000..2270f14
--- /dev/null
+++ b/internal/keyringstore/backend_e2e.go
@@ -0,0 +1,88 @@
+//go:build sshx_e2e
+
+package keyringstore
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// ErrNotFound mirrors the public behavior of the OS keyring provider.
+var ErrNotFound = errors.New("secret not found in isolated E2E keyring")
+
+type fileState map[string]map[string]string
+
+func Set(service, account, password string) error {
+ state, path, err := load()
+ if err != nil {
+ return err
+ }
+ if state[service] == nil {
+ state[service] = make(map[string]string)
+ }
+ state[service][account] = password
+ return save(path, state)
+}
+
+func Get(service, account string) (string, error) {
+ state, _, err := load()
+ if err != nil {
+ return "", err
+ }
+ value, ok := state[service][account]
+ if !ok {
+ return "", ErrNotFound
+ }
+ return value, nil
+}
+
+func Delete(service, account string) error {
+ state, path, err := load()
+ if err != nil {
+ return err
+ }
+ if _, ok := state[service][account]; !ok {
+ return ErrNotFound
+ }
+ delete(state[service], account)
+ return save(path, state)
+}
+
+func load() (fileState, string, error) {
+ path := os.Getenv("SSHX_E2E_KEYRING_FILE")
+ if path == "" {
+ return nil, "", fmt.Errorf("SSHX_E2E_KEYRING_FILE is required by the sshx_e2e build")
+ }
+ data, err := os.ReadFile(path) // #nosec G304 -- path is an explicit E2E-only fixture location.
+ if os.IsNotExist(err) {
+ return make(fileState), path, nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ state := make(fileState)
+ if err := json.Unmarshal(data, &state); err != nil {
+ return nil, "", fmt.Errorf("decode isolated E2E keyring: %w", err)
+ }
+ return state, path, nil
+}
+
+func save(path string, state fileState) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return fmt.Errorf("create isolated E2E keyring directory: %w", err)
+ }
+ data, err := json.Marshal(state)
+ if err != nil {
+ return fmt.Errorf("encode isolated E2E keyring: %w", err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil { // #nosec G306 -- E2E credential fixture must be owner-only.
+ return fmt.Errorf("write isolated E2E keyring: %w", err)
+ }
+ if err := os.Chmod(path, 0o600); err != nil {
+ return fmt.Errorf("secure isolated E2E keyring: %w", err)
+ }
+ return nil
+}
diff --git a/internal/keyringstore/backend_system.go b/internal/keyringstore/backend_system.go
new file mode 100644
index 0000000..b9a1c8f
--- /dev/null
+++ b/internal/keyringstore/backend_system.go
@@ -0,0 +1,20 @@
+//go:build !sshx_e2e
+
+package keyringstore
+
+import "github.com/zalando/go-keyring"
+
+// ErrNotFound reports that a key has no value in the operating-system keyring.
+var ErrNotFound = keyring.ErrNotFound
+
+func Set(service, account, password string) error {
+ return keyring.Set(service, account, password)
+}
+
+func Get(service, account string) (string, error) {
+ return keyring.Get(service, account)
+}
+
+func Delete(service, account string) error {
+ return keyring.Delete(service, account)
+}
diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go
index 7f5aef6..5060357 100644
--- a/internal/sshclient/client.go
+++ b/internal/sshclient/client.go
@@ -438,7 +438,13 @@ func shouldFallbackToPassword(err error, hadKeyAuth bool, hasPassword bool) bool
return false
}
var serverErr *ssh.ServerAuthError
- return errors.As(err, &serverErr)
+ if errors.As(err, &serverErr) {
+ return true
+ }
+ // x/crypto/ssh does not expose a client-side authentication error type.
+ // It reports this stable RFC 4252 terminal condition after all offered key
+ // methods are rejected. Do not fall back on transport or host-key failures.
+ return strings.Contains(err.Error(), "ssh: unable to authenticate, attempted methods")
}
// RunCommand executes the configured command and returns a structured result.
diff --git a/internal/sshclient/client_test.go b/internal/sshclient/client_test.go
index ace4759..ada35c7 100644
--- a/internal/sshclient/client_test.go
+++ b/internal/sshclient/client_test.go
@@ -270,6 +270,7 @@ func TestConfig_MultipleHosts(t *testing.T) {
func TestShouldFallbackToPassword(t *testing.T) {
authErr := &ssh.ServerAuthError{Errors: []error{fmt.Errorf("publickey denied")}}
+ clientAuthErr := fmt.Errorf("ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain")
t.Run("requires key auth present", func(t *testing.T) {
assert.False(t, shouldFallbackToPassword(authErr, false, true))
@@ -283,6 +284,14 @@ func TestShouldFallbackToPassword(t *testing.T) {
assert.True(t, shouldFallbackToPassword(authErr, true, true))
})
+ t.Run("client auth exhaustion triggers fallback", func(t *testing.T) {
+ assert.True(t, shouldFallbackToPassword(clientAuthErr, true, true))
+ })
+
+ t.Run("non-auth errors do not trigger fallback", func(t *testing.T) {
+ assert.False(t, shouldFallbackToPassword(fmt.Errorf("host key verification failed"), true, true))
+ })
+
t.Run("nil error", func(t *testing.T) {
assert.False(t, shouldFallbackToPassword(nil, true, true))
})
diff --git a/internal/sshclient/validate.go b/internal/sshclient/validate.go
index fb19c6d..d7c69dc 100644
--- a/internal/sshclient/validate.go
+++ b/internal/sshclient/validate.go
@@ -1,11 +1,12 @@
package sshclient
import (
+ "errors"
"fmt"
"strings"
+ "github.com/talkincode/sshx/internal/keyringstore"
"github.com/talkincode/sshx/pkg/logger"
- "github.com/zalando/go-keyring"
)
const KeyringServiceName = "sshx"
@@ -163,9 +164,9 @@ func isCommandWhitespace(ch byte) bool {
func GetSudoPassword(key string) (string, error) {
serviceName := KeyringServiceName
- password, err := keyring.Get(serviceName, key)
+ password, err := keyringstore.Get(serviceName, key)
if err != nil {
- if err == keyring.ErrNotFound {
+ if errors.Is(err, keyringstore.ErrNotFound) {
return "", fmt.Errorf("sudo password not found in keyring for key: %s\n"+
"Add it using one of:\n"+
" macOS: security add-generic-password -s %s -a %s -w \n"+
diff --git a/tests/e2e/cli_e2e_test.go b/tests/e2e/cli_e2e_test.go
new file mode 100644
index 0000000..82b3c19
--- /dev/null
+++ b/tests/e2e/cli_e2e_test.go
@@ -0,0 +1,299 @@
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/ssh/knownhosts"
+)
+
+func TestCLICommandReturnsStructuredResult(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+
+ result := runSSHX(t, home, []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--accept-unknown-host",
+ "--json",
+ "probe",
+ }, map[string]string{"SSH_PASSWORD": operatorPassword})
+
+ require.Equal(t, 0, result.exitCode, result.stderr)
+ var payload commandResult
+ require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload))
+ assert.True(t, payload.Success)
+ assert.Equal(t, 0, payload.ExitCode)
+ assert.Equal(t, "probe-ok\n", payload.Stdout)
+ assert.Empty(t, payload.Stderr)
+ assert.Equal(t, "password", payload.AuthMethod)
+}
+
+func TestCLIKeyAuthenticationAndExplicitPasswordFallback(t *testing.T) {
+ authorizedSigner, authorizedKeyPath := newClientKey(t)
+ server := startSSHServer(t, serverOptions{authorizedKey: authorizedSigner.PublicKey()})
+ home := t.TempDir()
+ base := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--json",
+ }
+
+ keyResult := runSSHX(t, home, append(append([]string{}, base...),
+ "-i="+authorizedKeyPath,
+ "--accept-unknown-host",
+ "probe",
+ ), nil)
+ require.Equal(t, 0, keyResult.exitCode, keyResult.stderr)
+ var keyPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(keyResult.stdout), &keyPayload))
+ assert.Equal(t, "key", keyPayload.AuthMethod)
+
+ _, rejectedKeyPath := newClientKey(t)
+ fallbackResult := runSSHX(t, home, append(append([]string{}, base...),
+ "-i="+rejectedKeyPath,
+ "probe",
+ ), map[string]string{"SSH_PASSWORD": operatorPassword})
+ require.Equal(t, 0, fallbackResult.exitCode, "stderr=%s stdout=%s", fallbackResult.stderr, fallbackResult.stdout)
+ var fallbackPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(fallbackResult.stdout), &fallbackPayload))
+ assert.Equal(t, "password-fallback", fallbackPayload.AuthMethod)
+ assert.Equal(t, "probe-ok\n", fallbackPayload.Stdout)
+}
+
+func TestCLIProcessDistinguishesRemoteExitAndStreams(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ base := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--accept-unknown-host",
+ "--json",
+ }
+
+ streams := runSSHX(t, home, append(append([]string{}, base...), "bothstreams"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, streams.exitCode, streams.stderr)
+ var streamsPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(streams.stdout), &streamsPayload))
+ assert.Equal(t, "to-out\n", streamsPayload.Stdout)
+ assert.Equal(t, "to-err\n", streamsPayload.Stderr)
+
+ remoteExit := runSSHX(t, home, append(append([]string{}, base...), "exit7"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 7, remoteExit.exitCode, remoteExit.stderr)
+ var exitPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(remoteExit.stdout), &exitPayload))
+ assert.False(t, exitPayload.Success)
+ assert.Equal(t, 7, exitPayload.ExitCode)
+ assert.Equal(t, "partial\n", exitPayload.Stdout)
+ assert.Empty(t, exitPayload.ErrorKind)
+}
+
+func TestCLIFailuresAreClassifiedAcrossTimeoutAuthAndHostTrust(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ base := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--json",
+ }
+
+ t.Run("unknown host is rejected until explicitly trusted", func(t *testing.T) {
+ home := t.TempDir()
+ unknown := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ assertSSHXFailure(t, unknown, "host_key")
+
+ trusted := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "probe"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, trusted.exitCode, trusted.stderr)
+
+ strictAfterTrust := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, strictAfterTrust.exitCode, strictAfterTrust.stderr)
+ })
+
+ t.Run("changed host key is rejected", func(t *testing.T) {
+ home := t.TempDir()
+ wrongSigner := newHostSigner(t)
+ knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
+ require.NoError(t, os.MkdirAll(filepath.Dir(knownHostsPath), 0o700))
+ pattern := fmt.Sprintf("[%s]:%s", server.host, server.port)
+ require.NoError(t, os.WriteFile(knownHostsPath, []byte(knownhosts.Line([]string{pattern}, wrongSigner.PublicKey())+"\n"), 0o600))
+
+ changed := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ assertSSHXFailure(t, changed, "host_key")
+ })
+
+ t.Run("authentication failure is distinct", func(t *testing.T) {
+ home := t.TempDir()
+ trust := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "probe"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, trust.exitCode, trust.stderr)
+
+ denied := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{
+ "SSH_PASSWORD": "wrong-password",
+ })
+ assertSSHXFailure(t, denied, "auth")
+ })
+
+ t.Run("command timeout is distinct", func(t *testing.T) {
+ home := t.TempDir()
+ timedOut := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "--timeout=100ms", "sleep"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ assertSSHXFailure(t, timedOut, "timeout")
+ })
+}
+
+func TestCLIPermissionsAndPartialCompletionAreObservable(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ operatorHome := t.TempDir()
+ operatorBase := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--json",
+ }
+
+ changed := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--accept-unknown-host", "set-state ready"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, changed.exitCode, changed.stderr)
+
+ readerHome := t.TempDir()
+ readerBase := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=reader",
+ "--no-key",
+ "--json",
+ }
+ denied := runSSHX(t, readerHome, append(append([]string{}, readerBase...), "--accept-unknown-host", "set-state forbidden"), map[string]string{
+ "SSH_PASSWORD": readerPassword,
+ })
+ require.Equal(t, 13, denied.exitCode, denied.stderr)
+ var deniedPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(denied.stdout), &deniedPayload))
+ assert.False(t, deniedPayload.Success)
+ assert.Equal(t, 13, deniedPayload.ExitCode)
+ assert.Equal(t, "permission denied\n", deniedPayload.Stderr)
+
+ uncertain := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "set-state-and-drop uncertain"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ assertSSHXFailure(t, uncertain, "exit_missing")
+
+ observed := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "read-state"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, observed.exitCode, observed.stderr)
+ var observedPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(observed.stdout), &observedPayload))
+ assert.Equal(t, "uncertain\n", observedPayload.Stdout, "callers must be able to inspect state after an ambiguous teardown")
+}
+
+func TestCLISafetyBlockPreventsConnectionAndForceIsExplicit(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ base := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--json",
+ }
+
+ connectionsBefore := server.connections.Load()
+ blocked := runSSHX(t, home, append(append([]string{}, base...), "rm -rf /"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ assertSSHXFailure(t, blocked, "blocked")
+ assert.Equal(t, connectionsBefore, server.connections.Load(), "blocked commands must not touch the network")
+
+ forced := runSSHX(t, home, append(append([]string{}, base...), "--force", "--accept-unknown-host", "rm -rf /"), map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ })
+ require.Equal(t, 0, forced.exitCode, forced.stderr)
+ var forcedPayload commandResult
+ require.NoError(t, json.Unmarshal([]byte(forced.stdout), &forcedPayload))
+ assert.Equal(t, "forced-ok\n", forcedPayload.Stdout)
+ assert.Greater(t, server.connections.Load(), connectionsBefore)
+}
+
+func TestCLIDryRunDescribesEffectsWithoutCrossingBoundaries(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ connectionsBefore := server.connections.Load()
+
+ result := runSSHX(t, home, []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--accept-unknown-host",
+ "--dry-run",
+ "--json",
+ "sudo whoami",
+ }, nil)
+ require.Equal(t, 0, result.exitCode, result.stderr)
+ var plan struct {
+ DryRun bool `json:"dry_run"`
+ Valid bool `json:"valid"`
+ Mode string `json:"mode"`
+ Action string `json:"action"`
+ UsesSudo bool `json:"uses_sudo"`
+ WouldConnect bool `json:"would_connect"`
+ WouldExecute bool `json:"would_execute"`
+ WouldReadSecret bool `json:"would_read_secret"`
+ WouldMutateRemote bool `json:"would_mutate_remote"`
+ MayMutateKnownHost bool `json:"may_mutate_known_hosts"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(result.stdout), &plan))
+ assert.True(t, plan.DryRun)
+ assert.True(t, plan.Valid)
+ assert.Equal(t, "ssh", plan.Mode)
+ assert.Equal(t, "command", plan.Action)
+ assert.True(t, plan.UsesSudo)
+ assert.True(t, plan.WouldConnect)
+ assert.True(t, plan.WouldExecute)
+ assert.True(t, plan.WouldReadSecret)
+ assert.True(t, plan.WouldMutateRemote)
+ assert.True(t, plan.MayMutateKnownHost)
+ assert.Equal(t, connectionsBefore, server.connections.Load())
+ _, err := os.Stat(filepath.Join(home, ".ssh", "known_hosts"))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+ _, err = os.Stat(filepath.Join(home, ".sshx", "audit"))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func assertSSHXFailure(t *testing.T, result cliResult, kind string) {
+ t.Helper()
+ require.Equal(t, 255, result.exitCode, result.stderr)
+ var payload commandResult
+ require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload))
+ assert.False(t, payload.Success)
+ assert.Equal(t, -1, payload.ExitCode)
+ assert.Equal(t, kind, payload.ErrorKind)
+}
diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go
new file mode 100644
index 0000000..8e7c896
--- /dev/null
+++ b/tests/e2e/harness_test.go
@@ -0,0 +1,418 @@
+package e2e
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/binary"
+ "encoding/pem"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/pkg/sftp"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/ssh"
+)
+
+const (
+ operatorPassword = "operator-pass" // #nosec G101 -- isolated E2E fixture credential.
+ readerPassword = "reader-pass" // #nosec G101 -- isolated E2E fixture credential.
+)
+
+var (
+ testBinary string
+ testKeyringBinary string
+ testRoot string
+)
+
+type commandResult struct {
+ Host string `json:"host"`
+ Port string `json:"port"`
+ ExitCode int `json:"exit_code"`
+ Success bool `json:"success"`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ AuthMethod string `json:"auth_method"`
+ ErrorKind string `json:"error_kind"`
+ Error string `json:"error"`
+}
+
+type cliResult struct {
+ stdout string
+ stderr string
+ exitCode int
+}
+
+type serverOptions struct {
+ root string
+ sftpReadOnly bool
+ authorizedKey ssh.PublicKey
+}
+
+type testSSHServer struct {
+ host string
+ port string
+ listener net.Listener
+ root string
+ sftpReadOnly bool
+ connections atomic.Int64
+ stateMu sync.Mutex
+ state string
+}
+
+func TestMain(m *testing.M) {
+ flag.Parse()
+ if !testing.Short() {
+ var err error
+ testRoot, err = os.MkdirTemp("", "sshx-e2e-")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "create E2E temp directory: %v\n", err)
+ os.Exit(1)
+ }
+ testBinary = filepath.Join(testRoot, "sshx")
+ testKeyringBinary = filepath.Join(testRoot, "sshx-keyring-e2e")
+ if runtime.GOOS == "windows" {
+ testBinary += ".exe"
+ testKeyringBinary += ".exe"
+ }
+ if output, buildErr := buildTestBinary(testBinary); buildErr != nil {
+ fmt.Fprintf(os.Stderr, "build sshx E2E binary: %v\n%s", buildErr, output)
+ _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup
+ os.Exit(1)
+ }
+ if output, buildErr := buildTestBinary(testKeyringBinary, "sshx_e2e"); buildErr != nil {
+ fmt.Fprintf(os.Stderr, "build sshx keyring E2E binary: %v\n%s", buildErr, output)
+ _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup
+ os.Exit(1)
+ }
+ }
+
+ code := m.Run()
+ if testRoot != "" {
+ _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup
+ }
+ os.Exit(code)
+}
+
+func buildTestBinary(output string, tags ...string) ([]byte, error) {
+ args := []string{"build"}
+ if len(tags) > 0 {
+ args = append(args, "-tags", strings.Join(tags, ","))
+ }
+ args = append(args, "-o", output, "./cmd/sshx")
+ build := exec.Command("go", args...)
+ build.Dir = repositoryRoot()
+ return build.CombinedOutput()
+}
+
+func repositoryRoot() string {
+ _, filename, _, ok := runtime.Caller(0)
+ if !ok {
+ panic("cannot resolve E2E harness path")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
+}
+
+func startSSHServer(t *testing.T, options serverOptions) *testSSHServer {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping compiled-binary E2E in short mode")
+ }
+
+ signer := newHostSigner(t)
+
+ config := &ssh.ServerConfig{
+ PasswordCallback: func(metadata ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
+ switch {
+ case metadata.User() == "operator" && string(password) == operatorPassword:
+ return &ssh.Permissions{Extensions: map[string]string{"role": "operator"}}, nil
+ case metadata.User() == "reader" && string(password) == readerPassword:
+ return &ssh.Permissions{Extensions: map[string]string{"role": "reader"}}, nil
+ }
+ return nil, fmt.Errorf("invalid E2E credentials")
+ },
+ PublicKeyCallback: func(metadata ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
+ if metadata.User() == "operator" && options.authorizedKey != nil &&
+ bytes.Equal(key.Marshal(), options.authorizedKey.Marshal()) {
+ return &ssh.Permissions{Extensions: map[string]string{"role": "operator"}}, nil
+ }
+ return nil, fmt.Errorf("invalid E2E public key")
+ },
+ }
+ config.AddHostKey(signer)
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ host, port, err := net.SplitHostPort(listener.Addr().String())
+ require.NoError(t, err)
+ if options.root == "" {
+ options.root = t.TempDir()
+ }
+
+ server := &testSSHServer{
+ host: host, port: port, listener: listener,
+ root: options.root, sftpReadOnly: options.sftpReadOnly,
+ }
+ t.Cleanup(func() { _ = listener.Close() }) //nolint:errcheck // best-effort E2E teardown
+
+ go server.serve(config)
+ return server
+}
+
+func newHostSigner(t *testing.T) ssh.Signer {
+ t.Helper()
+ _, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+ signer, err := ssh.NewSignerFromKey(privateKey)
+ require.NoError(t, err)
+ return signer
+}
+
+func newClientKey(t *testing.T) (ssh.Signer, string) {
+ t.Helper()
+ _, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+ signer, err := ssh.NewSignerFromKey(privateKey)
+ require.NoError(t, err)
+ block, err := ssh.MarshalPrivateKey(privateKey, "sshx-e2e")
+ require.NoError(t, err)
+ path := filepath.Join(t.TempDir(), "id_ed25519")
+ require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
+ return signer, path
+}
+
+func (s *testSSHServer) serve(config *ssh.ServerConfig) {
+ for {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ return
+ }
+ s.connections.Add(1)
+ go handleSSHConnection(conn, config, s)
+ }
+}
+
+func handleSSHConnection(conn net.Conn, config *ssh.ServerConfig, server *testSSHServer) {
+ serverConn, channels, requests, err := ssh.NewServerConn(conn, config)
+ if err != nil {
+ _ = conn.Close() //nolint:errcheck // handshake did not establish an SSH connection
+ return
+ }
+ defer func() { _ = serverConn.Close() }() //nolint:errcheck // best-effort E2E teardown
+ go ssh.DiscardRequests(requests)
+
+ for newChannel := range channels {
+ if newChannel.ChannelType() != "session" {
+ _ = newChannel.Reject(ssh.UnknownChannelType, "session channels only") //nolint:errcheck // test protocol response
+ continue
+ }
+ channel, channelRequests, acceptErr := newChannel.Accept()
+ if acceptErr != nil {
+ continue
+ }
+ role := ""
+ if serverConn.Permissions != nil {
+ role = serverConn.Permissions.Extensions["role"]
+ }
+ go handleSSHSession(channel, channelRequests, server, role)
+ }
+}
+
+func handleSSHSession(channel ssh.Channel, requests <-chan *ssh.Request, server *testSSHServer, role string) {
+ defer func() { _ = channel.Close() }() //nolint:errcheck // best-effort E2E teardown
+ for request := range requests {
+ if request.Type == "subsystem" {
+ var payload struct{ Name string }
+ if err := ssh.Unmarshal(request.Payload, &payload); err != nil || payload.Name != "sftp" {
+ _ = request.Reply(false, nil) //nolint:errcheck // unsupported fixture subsystem
+ return
+ }
+ _ = request.Reply(true, nil) //nolint:errcheck // test protocol response
+ options := []sftp.ServerOption{sftp.WithServerWorkingDirectory(server.root)}
+ if role == "reader" || server.sftpReadOnly {
+ options = append(options, sftp.ReadOnly())
+ }
+ sftpServer, err := sftp.NewServer(channel, options...)
+ if err != nil {
+ return
+ }
+ _ = sftpServer.Serve() //nolint:errcheck // client disconnect ends the isolated fixture server
+ _ = sftpServer.Close() //nolint:errcheck // best-effort fixture teardown
+ return
+ }
+ if request.Type != "exec" {
+ if request.WantReply {
+ _ = request.Reply(false, nil) //nolint:errcheck // test protocol response
+ }
+ continue
+ }
+
+ var payload struct{ Command string }
+ if err := ssh.Unmarshal(request.Payload, &payload); err != nil {
+ _ = request.Reply(false, nil) //nolint:errcheck // malformed test request
+ return
+ }
+ _ = request.Reply(true, nil) //nolint:errcheck // test protocol response
+
+ exitCode := uint32(0)
+ switch {
+ case payload.Command == "probe" || strings.HasPrefix(payload.Command, "probe "):
+ _, _ = io.WriteString(channel, "probe-ok\n") //nolint:errcheck // fixture response
+ case payload.Command == "bothstreams":
+ _, _ = io.WriteString(channel, "to-out\n") //nolint:errcheck // fixture response
+ _, _ = io.WriteString(channel.Stderr(), "to-err\n") //nolint:errcheck // fixture response
+ case payload.Command == "exit7":
+ exitCode = 7
+ _, _ = io.WriteString(channel, "partial\n") //nolint:errcheck // fixture response
+ case payload.Command == "sleep":
+ time.Sleep(2 * time.Second)
+ case payload.Command == "read-state":
+ _, _ = io.WriteString(channel, server.readState()+"\n") //nolint:errcheck // fixture response
+ case strings.HasPrefix(payload.Command, "set-state-and-drop "):
+ if role != "operator" {
+ _, _ = io.WriteString(channel.Stderr(), "permission denied\n") //nolint:errcheck // fixture response
+ sendExitStatus(channel, 13)
+ return
+ }
+ server.writeState(strings.TrimPrefix(payload.Command, "set-state-and-drop "))
+ _, _ = io.WriteString(channel, "state-updated\n") //nolint:errcheck // fixture response
+ return
+ case strings.HasPrefix(payload.Command, "set-state "):
+ if role != "operator" {
+ exitCode = 13
+ _, _ = io.WriteString(channel.Stderr(), "permission denied\n") //nolint:errcheck // fixture response
+ break
+ }
+ server.writeState(strings.TrimPrefix(payload.Command, "set-state "))
+ _, _ = io.WriteString(channel, "state-updated\n") //nolint:errcheck // fixture response
+ case payload.Command == "rm -rf /":
+ _, _ = io.WriteString(channel, "forced-ok\n") //nolint:errcheck // fixture response
+ case payload.Command == "sudo -S -p '' whoami":
+ password, err := bufio.NewReader(channel).ReadString('\n')
+ if err != nil && !errors.Is(err, io.EOF) {
+ exitCode = 24
+ _, _ = io.WriteString(channel.Stderr(), "sudo stdin read failed\n") //nolint:errcheck // fixture response
+ break
+ }
+ if strings.TrimSpace(password) != operatorPassword {
+ exitCode = 25
+ _, _ = io.WriteString(channel.Stderr(), "sudo password mismatch\n") //nolint:errcheck // fixture response
+ break
+ }
+ _, _ = io.WriteString(channel, "sudo-ok\n") //nolint:errcheck // fixture response
+ default:
+ exitCode = 127
+ _, _ = io.WriteString(channel.Stderr(), "unknown fixture command\n") //nolint:errcheck // fixture response
+ }
+ sendExitStatus(channel, exitCode)
+ return
+ }
+}
+
+func (s *testSSHServer) readState() string {
+ s.stateMu.Lock()
+ defer s.stateMu.Unlock()
+ return s.state
+}
+
+func (s *testSSHServer) writeState(value string) {
+ s.stateMu.Lock()
+ defer s.stateMu.Unlock()
+ s.state = value
+}
+
+func sendExitStatus(channel ssh.Channel, status uint32) {
+ payload := make([]byte, 4)
+ binary.BigEndian.PutUint32(payload, status)
+ _, _ = channel.SendRequest("exit-status", false, payload) //nolint:errcheck // fixture response
+}
+
+func runSSHX(t *testing.T, home string, args []string, extraEnv map[string]string) cliResult {
+ return runSSHXBinary(t, testBinary, home, args, extraEnv)
+}
+
+func runSSHXWithTestKeyring(t *testing.T, home string, args []string, extraEnv map[string]string) cliResult {
+ return runSSHXBinary(t, testKeyringBinary, home, args, extraEnv)
+}
+
+func runSSHXBinary(t *testing.T, binary, home string, args []string, extraEnv map[string]string) cliResult {
+ return runSSHXBinaryWithHome(t, binary, home, home, args, extraEnv)
+}
+
+func runSSHXWithNativeKeyring(t *testing.T, workDir string, args []string, extraEnv map[string]string) cliResult {
+ t.Helper()
+ nativeHome := os.Getenv("HOME")
+ require.NotEmpty(t, nativeHome, "native keyring E2E requires the platform user HOME")
+ return runSSHXBinaryWithHome(t, testBinary, workDir, nativeHome, args, extraEnv)
+}
+
+func runSSHXBinaryWithHome(t *testing.T, binary, workDir, environmentHome string, args []string, extraEnv map[string]string) cliResult {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping compiled-binary E2E in short mode")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ command := exec.CommandContext(ctx, binary, args...) // #nosec G204 -- binary is one of the harness-built sshx executables and args are isolated test inputs.
+ command.Dir = workDir
+ command.Env = isolatedEnvironment(environmentHome, extraEnv)
+ var stdout, stderr bytes.Buffer
+ command.Stdout = &stdout
+ command.Stderr = &stderr
+
+ err := command.Run()
+ exitCode := 0
+ if err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ exitCode = exitErr.ExitCode()
+ } else {
+ require.NoError(t, err)
+ }
+ }
+ require.NoError(t, ctx.Err(), "sshx E2E process timed out")
+ return cliResult{stdout: stdout.String(), stderr: stderr.String(), exitCode: exitCode}
+}
+
+func isolatedEnvironment(home string, extra map[string]string) []string {
+ blocked := map[string]struct{}{
+ "HOME": {}, "SSH_PASSWORD": {}, "SSH_KEY_PATH": {}, "SSH_SUDO_KEY": {},
+ "SSH_DISABLE_KEY": {}, "SSH_KNOWN_HOSTS": {}, "SSHX_AUDIT_OUTPUT": {},
+ "SSHX_NO_AUDIT": {}, "SSH_ACCEPT_UNKNOWN_HOST": {}, "SSH_INSECURE_HOST_KEY": {},
+ "SSH_NO_SAFETY_CHECK": {}, "SSH_FORCE": {}, "SSH_TIMEOUT": {}, "SSHX_LOG_LEVEL": {},
+ }
+ env := make([]string, 0, len(os.Environ())+len(extra)+3)
+ for _, item := range os.Environ() {
+ name, _, _ := strings.Cut(item, "=")
+ if _, found := blocked[name]; !found {
+ env = append(env, item)
+ }
+ }
+ values := map[string]string{
+ "HOME": home,
+ "SSHX_NO_AUDIT": "true",
+ "SSHX_LOG_LEVEL": "error",
+ }
+ for name, value := range extra {
+ values[name] = value
+ }
+ for name, value := range values {
+ env = append(env, name+"="+value)
+ }
+ return env
+}
diff --git a/tests/e2e/host_audit_e2e_test.go b/tests/e2e/host_audit_e2e_test.go
new file mode 100644
index 0000000..7f3ca28
--- /dev/null
+++ b/tests/e2e/host_audit_e2e_test.go
@@ -0,0 +1,121 @@
+package e2e
+
+import (
+ "bufio"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCLIHostImportIsUsableAndFailedSelectionIsAllOrNothing(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ sshConfig := filepath.Join(home, "ssh_config")
+ configText := "Host imported\n" +
+ " HostName " + server.host + "\n" +
+ " Port " + server.port + "\n" +
+ " User operator\n\n" +
+ "Host second\n" +
+ " HostName " + server.host + "\n" +
+ " Port 22\n" +
+ " User operator\n"
+ require.NoError(t, os.WriteFile(sshConfig, []byte(configText), 0o600))
+
+ imported := runSSHX(t, home, []string{
+ "--host-import=imported",
+ "--ssh-config=" + sshConfig,
+ "--no-audit",
+ }, nil)
+ require.Equal(t, 0, imported.exitCode, imported.stderr)
+ settingsPath := filepath.Join(home, ".sshx", "settings.json")
+ settingsBeforeFailure, err := os.ReadFile(settingsPath) // #nosec G304 -- path is inside this test's temporary HOME.
+ require.NoError(t, err)
+ info, err := os.Stat(settingsPath)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ failed := runSSHX(t, home, []string{
+ "--host-import=second,missing",
+ "--ssh-config=" + sshConfig,
+ "--no-audit",
+ }, nil)
+ require.Equal(t, 255, failed.exitCode)
+ settingsAfterFailure, err := os.ReadFile(settingsPath) // #nosec G304 -- path is inside this test's temporary HOME.
+ require.NoError(t, err)
+ assert.Equal(t, settingsBeforeFailure, settingsAfterFailure, "failed selection must not partially update settings")
+
+ command := runSSHX(t, home, []string{
+ "-h=imported",
+ "--no-key",
+ "--accept-unknown-host",
+ "--json",
+ "probe",
+ }, map[string]string{"SSH_PASSWORD": operatorPassword})
+ require.Equal(t, 0, command.exitCode, command.stderr)
+ var payload commandResult
+ require.NoError(t, json.Unmarshal([]byte(command.stdout), &payload))
+ assert.Equal(t, server.host, payload.Host)
+ assert.Equal(t, server.port, payload.Port)
+}
+
+func TestCLIAuditRedactsSecretsAndRecoversAfterWriteFailure(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ knownHostArgs := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--json",
+ }
+ env := map[string]string{
+ "SSH_PASSWORD": operatorPassword,
+ "SSHX_NO_AUDIT": "false",
+ }
+
+ invalidAuditTarget := filepath.Join(home, "audit-is-a-file")
+ require.NoError(t, os.WriteFile(invalidAuditTarget, []byte("not a directory"), 0o600))
+ writeFailure := runSSHX(t, home, append(append([]string{}, knownHostArgs...),
+ "--accept-unknown-host", "--audit-output="+invalidAuditTarget, "probe"), env)
+ require.Equal(t, 0, writeFailure.exitCode, writeFailure.stderr)
+ assert.Contains(t, writeFailure.stderr, "failed to write audit event")
+
+ auditDir := filepath.Join(home, "audit")
+ const fakeSecret = "fixture-secret-value" // #nosec G101 -- redaction fixture, not a credential.
+ audited := runSSHX(t, home, append(append([]string{}, knownHostArgs...),
+ "--audit-output="+auditDir, "probe --token="+fakeSecret), env)
+ require.Equal(t, 0, audited.exitCode, audited.stderr)
+
+ entries, err := os.ReadDir(auditDir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ file, err := os.Open(filepath.Join(auditDir, entries[0].Name())) // #nosec G304 -- isolated test directory entry.
+ require.NoError(t, err)
+ defer func() { _ = file.Close() }() //nolint:errcheck // best-effort fixture cleanup
+ scanner := bufio.NewScanner(file)
+ require.True(t, scanner.Scan())
+ var event struct {
+ Mode string `json:"mode"`
+ Command string `json:"command"`
+ AuthMethod string `json:"auth_method"`
+ Outcome struct {
+ Status string `json:"status"`
+ } `json:"outcome"`
+ Redaction struct {
+ Secrets bool `json:"secrets_redacted"`
+ } `json:"redaction"`
+ }
+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
+ assert.Equal(t, "ssh", event.Mode)
+ assert.Equal(t, "password", event.AuthMethod)
+ assert.Equal(t, "success", event.Outcome.Status)
+ assert.True(t, event.Redaction.Secrets)
+ assert.NotContains(t, event.Command, fakeSecret)
+ assert.Contains(t, strings.ToLower(event.Command), "redacted")
+ assert.False(t, scanner.Scan(), "one invocation must produce one audit event")
+}
diff --git a/tests/e2e/keyring_e2e_test.go b/tests/e2e/keyring_e2e_test.go
new file mode 100644
index 0000000..f467d33
--- /dev/null
+++ b/tests/e2e/keyring_e2e_test.go
@@ -0,0 +1,99 @@
+package e2e
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsolatedKeyringProcessContractAndSudoExecution(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ home := t.TempDir()
+ keyringFile := filepath.Join(home, "keyring.json")
+ key := "isolated-key"
+ env := map[string]string{"SSHX_E2E_KEYRING_FILE": keyringFile}
+
+ set := runSSHXWithTestKeyring(t, home, []string{"--password-set=" + key + ":" + operatorPassword, "--no-audit"}, env)
+ require.Equal(t, 0, set.exitCode, set.stderr)
+ info, err := os.Stat(keyringFile)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ get := runSSHXWithTestKeyring(t, home, []string{"--password-get=" + key, "--no-audit"}, env)
+ require.Equal(t, 0, get.exitCode, get.stderr)
+ assert.Equal(t, operatorPassword, get.stdout)
+
+ sudo := runSSHXWithTestKeyring(t, home, []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--accept-unknown-host",
+ "--json",
+ "-pk=" + key,
+ "sudo whoami",
+ }, env)
+ require.Equal(t, 0, sudo.exitCode, sudo.stderr)
+ var payload commandResult
+ require.NoError(t, json.Unmarshal([]byte(sudo.stdout), &payload))
+ assert.Equal(t, "sudo-ok\n", payload.Stdout)
+ assert.NotContains(t, sudo.stdout, operatorPassword)
+ assert.NotContains(t, sudo.stderr, operatorPassword)
+
+ deleted := runSSHXWithTestKeyring(t, home, []string{"--password-delete=" + key, "--no-audit"}, env)
+ require.Equal(t, 0, deleted.exitCode, deleted.stderr)
+ missing := runSSHXWithTestKeyring(t, home, []string{"--password-get=" + key, "--no-audit"}, env)
+ assert.Equal(t, 255, missing.exitCode)
+}
+
+func TestRealOSKeyringLifecycleAndSudoExecution(t *testing.T) {
+ if os.Getenv("SSHX_E2E_REAL_KEYRING") != "1" {
+ t.Skip("set SSHX_E2E_REAL_KEYRING=1 only in an isolated or ephemeral OS keyring session")
+ }
+ server := startSSHServer(t, serverOptions{})
+ workDir := t.TempDir()
+ key := "sshx-e2e-" + randomHex(t, 8)
+
+ set := runSSHXWithNativeKeyring(t, workDir, []string{"--password-set=" + key + ":" + operatorPassword, "--no-audit"}, nil)
+ require.Equal(t, 0, set.exitCode, set.stderr)
+ t.Cleanup(func() {
+ cleanup := runSSHXWithNativeKeyring(t, workDir, []string{"--password-delete=" + key, "--no-audit"}, nil)
+ assert.Equal(t, 0, cleanup.exitCode, cleanup.stderr)
+ })
+
+ get := runSSHXWithNativeKeyring(t, workDir, []string{"--password-get=" + key, "--no-audit"}, nil)
+ require.Equal(t, 0, get.exitCode, get.stderr)
+ assert.Equal(t, operatorPassword, get.stdout)
+
+ sudo := runSSHXWithNativeKeyring(t, workDir, []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ "--accept-unknown-host",
+ "--known-hosts=" + filepath.Join(workDir, "known_hosts"),
+ "--json",
+ "-pk=" + key,
+ "sudo whoami",
+ }, nil)
+ require.Equal(t, 0, sudo.exitCode, sudo.stderr)
+ var payload commandResult
+ require.NoError(t, json.Unmarshal([]byte(sudo.stdout), &payload))
+ assert.Equal(t, "sudo-ok\n", payload.Stdout)
+ assert.NotContains(t, sudo.stdout, operatorPassword)
+ assert.NotContains(t, sudo.stderr, operatorPassword)
+}
+
+func randomHex(t *testing.T, bytesLen int) string {
+ t.Helper()
+ data := make([]byte, bytesLen)
+ _, err := rand.Read(data)
+ require.NoError(t, err)
+ return hex.EncodeToString(data)
+}
diff --git a/tests/e2e/sftp_e2e_test.go b/tests/e2e/sftp_e2e_test.go
new file mode 100644
index 0000000..4427eba
--- /dev/null
+++ b/tests/e2e/sftp_e2e_test.go
@@ -0,0 +1,147 @@
+package e2e
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCLISFTPWorkflowCoversWriteReadRemoveAndPermissionFailure(t *testing.T) {
+ server := startSSHServer(t, serverOptions{})
+ operatorHome := t.TempDir()
+ localSource := filepath.Join(operatorHome, "local.txt")
+ require.NoError(t, os.WriteFile(localSource, []byte("sftp-payload\n"), 0o600))
+ operatorBase := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=operator",
+ "--no-key",
+ }
+ operatorEnv := map[string]string{"SSH_PASSWORD": operatorPassword}
+
+ upload := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...),
+ "--accept-unknown-host", "--upload="+localSource, "--to=uploaded.txt"), operatorEnv)
+ require.Equal(t, 0, upload.exitCode, upload.stderr)
+ remoteContent, err := os.ReadFile(filepath.Join(server.root, "uploaded.txt"))
+ require.NoError(t, err)
+ assert.Equal(t, "sftp-payload\n", string(remoteContent))
+
+ listing := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--list=."), operatorEnv)
+ require.Equal(t, 0, listing.exitCode, listing.stderr)
+ assert.Contains(t, listing.stdout, "uploaded.txt")
+
+ localDownload := filepath.Join(operatorHome, "downloaded.txt")
+ download := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...),
+ "--download=uploaded.txt", "--to="+localDownload), operatorEnv)
+ require.Equal(t, 0, download.exitCode, download.stderr)
+ downloaded, err := os.ReadFile(localDownload) // #nosec G304 -- path is inside this test's temporary HOME.
+ require.NoError(t, err)
+ assert.Equal(t, "sftp-payload\n", string(downloaded))
+
+ mkdir := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--mkdir=nested/dir"), operatorEnv)
+ require.Equal(t, 0, mkdir.exitCode, mkdir.stderr)
+ info, err := os.Stat(filepath.Join(server.root, "nested", "dir"))
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ removeDir := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--rm=nested"), operatorEnv)
+ require.Equal(t, 0, removeDir.exitCode, removeDir.stderr)
+ _, err = os.Stat(filepath.Join(server.root, "nested"))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+
+ readerHome := t.TempDir()
+ readerBase := []string{
+ "-h=" + server.host,
+ "-p=" + server.port,
+ "-u=reader",
+ "--no-key",
+ }
+ readerEnv := map[string]string{"SSH_PASSWORD": readerPassword}
+ readerDownload := filepath.Join(readerHome, "reader-copy.txt")
+ readAllowed := runSSHX(t, readerHome, append(append([]string{}, readerBase...),
+ "--accept-unknown-host", "--download=uploaded.txt", "--to="+readerDownload), readerEnv)
+ require.Equal(t, 0, readAllowed.exitCode, readAllowed.stderr)
+
+ readerSource := filepath.Join(readerHome, "forbidden.txt")
+ require.NoError(t, os.WriteFile(readerSource, []byte("must-not-land"), 0o600))
+ writeDenied := runSSHX(t, readerHome, append(append([]string{}, readerBase...),
+ "--upload="+readerSource, "--to=forbidden.txt"), readerEnv)
+ require.Equal(t, 255, writeDenied.exitCode)
+ assert.Contains(t, writeDenied.stderr, "permission denied")
+ _, err = os.Stat(filepath.Join(server.root, "forbidden.txt"))
+ assert.ErrorIs(t, err, os.ErrNotExist, "failed upload must not leave a destination file")
+}
+
+func TestCLIServerToServerTransferCoversSuccessFailureAndRecovery(t *testing.T) {
+ source := startSSHServer(t, serverOptions{})
+ destination := startSSHServer(t, serverOptions{})
+ locked := startSSHServer(t, serverOptions{sftpReadOnly: true})
+ require.NoError(t, os.WriteFile(filepath.Join(source.root, "payload.txt"), []byte("streamed-between-hosts\n"), 0o600))
+
+ home := t.TempDir()
+ writeNamedHosts(t, home, map[string]*testSSHServer{
+ "source": source,
+ "dest": destination,
+ "locked": locked,
+ })
+ env := map[string]string{"SSH_PASSWORD": operatorPassword}
+
+ success := runSSHX(t, home, []string{
+ "--transfer=source:payload.txt",
+ "--to=dest:received.txt",
+ "--no-key",
+ "--accept-unknown-host",
+ }, env)
+ require.Equal(t, 0, success.exitCode, success.stderr)
+ content, err := os.ReadFile(filepath.Join(destination.root, "received.txt"))
+ require.NoError(t, err)
+ assert.Equal(t, "streamed-between-hosts\n", string(content))
+
+ failed := runSSHX(t, home, []string{
+ "--transfer=source:payload.txt",
+ "--to=locked:partial.txt",
+ "--no-key",
+ "--accept-unknown-host",
+ }, env)
+ require.Equal(t, 255, failed.exitCode)
+ assert.Contains(t, failed.stderr, "permission denied")
+ _, err = os.Stat(filepath.Join(locked.root, "partial.txt"))
+ assert.ErrorIs(t, err, os.ErrNotExist, "failed transfer must not leave a destination file")
+
+ recovered := runSSHX(t, home, []string{
+ "--transfer=source:payload.txt",
+ "--to=dest:recovered.txt",
+ "--no-key",
+ }, env)
+ require.Equal(t, 0, recovered.exitCode, recovered.stderr)
+ recoveredContent, err := os.ReadFile(filepath.Join(destination.root, "recovered.txt"))
+ require.NoError(t, err)
+ assert.Equal(t, "streamed-between-hosts\n", string(recoveredContent))
+}
+
+func writeNamedHosts(t *testing.T, home string, servers map[string]*testSSHServer) {
+ t.Helper()
+ type host struct {
+ Name string `json:"name"`
+ Host string `json:"host"`
+ Port string `json:"port"`
+ User string `json:"user"`
+ }
+ type settings struct {
+ Hosts []host `json:"hosts"`
+ }
+
+ fixture := settings{Hosts: make([]host, 0, len(servers))}
+ for name, server := range servers {
+ fixture.Hosts = append(fixture.Hosts, host{Name: name, Host: server.host, Port: server.port, User: "operator"})
+ }
+ data, err := json.Marshal(fixture)
+ require.NoError(t, err)
+ dir := filepath.Join(home, ".sshx")
+ require.NoError(t, os.MkdirAll(dir, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "settings.json"), data, 0o600))
+}