Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,11 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman
usage.go → PrintUsage() help text (keep in sync with flags)
dryrun.go → --dry-run local execution plan preview
audit.go → local structured JSONL audit events + redaction
run.go → sshx run: selectors, scripts, fan-out, versioned results
skill.go → install the canonical Agent skill embedded in sshx
plugin.go → local plugin create/list/show/validate/test/trust/remove
inspect.go → one-shot capability execution + observation caching
internal/execution/ → versioned request/result model, selectors, executor
internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins
internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution
internal/skillinstall/ → conflict-safe, atomic Agent skill installation
Expand All @@ -126,6 +128,7 @@ skills/ → canonical Agent skill plus its embedded asset packa
| Mode | Trigger | Responsibility |
|------------|-------------------------------------------|-----------------------------------------|
| `ssh` | default; a command argument is present | run a remote command (sudo auto-fill) |
| `run` | `sshx run ...` | canonical multi-host/script execution |
| `sftp` | `--upload/--download/--list/--mkdir/--rm` | file transfer & remote FS ops |
| `password` | `--password-*` | manage keyring secrets |
| `host` | `--host-*` | manage `settings.json` host entries |
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add the canonical `sshx run` execution contract with versioned request/result
models, strict target selectors (`--target`/`--targets`/`--group`/`--tag`/
`--all-hosts`/`--address`), byte-preserving `--script-file`/`--script-stdin`
payloads (SHA-256 digest + size limits), bounded multi-host fan-out
(`--concurrency` default 4 / hard max 32), `--failure-mode=continue|fail_fast`,
and JSONL run events (`run_started` / `target_*` / `run_finished`).
- Extend host inventory with `groups`, `tags`, `ssh_password_key`, and
`sudo_password_key` while keeping legacy `password_key` as a sudo-only alias.
- Correlate audit events with `run_id`, selector/payload digests, action intent,
bypass reason, and per-target completion certainty.

### Changed

- Stop implicitly loading a working-directory `.env` file.
- High-risk trust relaxations (`force`, safety-check disablement, unknown-host
acceptance, insecure host-key mode) now require explicit CLI/request fields;
inherited environment values are ignored with a diagnostic.
- Host diagnostics and execution paths no longer treat sudo password keys as SSH
login credentials.

### Security

- Separate SSH-login and sudo credential roles end-to-end so a host with only
`sudo_password_key` never attempts password authentication.
- Safety bypass on `sshx run` requires a non-empty `--bypass-reason` recorded in
dry-run, result, and audit metadata.

## [0.2.0] - 2026-08-12

### Added
Expand Down
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ It remains a single binary with one-shot invocations and no resident component o

## Key Features

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.
8. One-shot host inspection with built-in system/network capabilities, local
1. Agent-friendly JSON/JSONL, stable exit codes, separated stdout/stderr, and classified failures.
2. Canonical `sshx run` contract: strict selectors, byte-preserving scripts, and bounded multi-host fan-out.
3. Dry-run execution plans and default-on local structured auditing with safe redaction.
4. Named host management with groups/tags and selective OpenSSH config import.
5. Strict host-key verification, destructive-command guardrails, and explicit bypass semantics.
6. OS-keyring password management with distinct SSH-login and sudo credential roles.
7. Cross-platform SSH/SFTP command and file actions.
8. Direct server-to-server transfer, streamed through the local machine without touching local disk.
9. One-shot host inspection with built-in system/network capabilities, local
sshx-owned plugins, explicit digest trust, and freshness-bounded observations.

## Installation
Expand Down Expand Up @@ -277,6 +278,20 @@ On an `sshx`-level failure the object has `exit_code: -1` and a non-empty
`exit_missing`, `config`, `error`), so it is always distinguishable from a
remote command that happens to exit `255`.

### Canonical `sshx run` contract

Prefer `sshx run` for strict aliases, complex scripts, and bounded multi-host
execution:

```bash
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 --json
```

Multi-target exit codes: `0` all succeeded, `1` partial failure/skip/uncertain,
`255` request-level failure (invalid selectors, zero matches, bad input).

### `--dry-run` execution plan preview

Add `--dry-run` to see how `sshx` would interpret an invocation before it opens
Expand Down
29 changes: 21 additions & 8 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组

## 核心特性

1. Agent 友好的 JSON、稳定退出码、stdout/stderr 分离和错误分类。
2. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。
3. 命名主机管理和 OpenSSH config 选择性导入,支持每台主机独立 SSH key。
4. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。
5. 系统密钥链密码管理和通过 stdin 完成的 sudo 自动填充。
6. 跨平台 SSH/SFTP 命令与文件动作。
7. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。
8. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理,
1. Agent 友好的 JSON/JSONL、稳定退出码、stdout/stderr 分离和错误分类。
2. 规范 `sshx run` 契约:严格选择器、脚本字节保真、有界多主机并发执行。
3. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。
4. 命名主机管理(groups/tags)和 OpenSSH config 选择性导入。
5. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。
6. 系统密钥链密码管理,SSH 登录与 sudo 凭据角色分离。
7. 跨平台 SSH/SFTP 命令与文件动作。
8. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。
9. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理,
支持摘要信任和有有效期的观察快照。

## 安装
Expand Down Expand Up @@ -279,6 +280,18 @@ sshx -h=prod-web --json "systemctl is-active nginx"
`timeout`、`auth`、`host_key`、`connect`、`blocked`、`exit_missing`、`config`、`error`
之一),因此始终可以与"远程命令恰好退出 255"区分开来。

### 规范契约 `sshx run`

严格别名、复杂脚本和有界多主机执行请优先使用:

```bash
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 --json
```

多主机退出码:`0` 全部成功,`1` 部分失败/跳过/不确定,`255` 请求级失败。

### `--dry-run` 执行计划预览

加上 `--dry-run` 可以在真正连接 SSH、执行命令、执行 SFTP 操作、读取 keyring 明文、
Expand Down
20 changes: 20 additions & 0 deletions docs/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

`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:

```bash
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.
- Dry-run and results expose payload SHA-256 and byte length, not raw script contents.
- Multi-target `--jsonl` streams `run_started`, per-target events, and `run_finished`.
- Multi-target exit codes: `0` all succeeded, `1` partial/failed/skipped/uncertain, `255` request-level failure.
- High-risk bypasses require explicit flags; `sshx run` also requires `--bypass-reason=`.
- Working-directory `.env` files are not loaded. Inherited `SSH_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.
Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ Agent / 自动化 / 人类运维者
| 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` |
| 有界多主机执行(方向) | 高 | 是 | 可能,多主机 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | `--host-test-all` 仅覆盖连接测试,不等同批量执行 |
| 可解释执行治理(方向) | 高 | 是 | 可能 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | 现有 `--dry-run`、安全检查与审计是基础,不构成完整能力 |
| 有界多主机执行 | 高 | 是 | 可能,多主机 | ✅ `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` |

当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。
18 changes: 18 additions & 0 deletions docs/zh/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

`sshx` 设计上可以被脚本和 AI agent 调用。契约很简单:稳定的 stdout/stderr、稳定退出码、可选 JSON、可选本地审计事件。

## 规范执行契约 `sshx run`

复杂脚本、严格别名选择和有界多主机执行请优先使用:

```bash
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` 拼装。
- 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 会保持分离,也不会把终端控制字符混进脚本输出。
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module github.com/talkincode/sshx
go 1.25.10

require (
github.com/joho/godotenv v1.5.1
github.com/pkg/sftp v1.13.10
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/stretchr/testify v1.11.1
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
Expand Down
39 changes: 29 additions & 10 deletions internal/app/agentmode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,17 +430,19 @@ func TestRun_DryRunResolvesNamedHostAndSudoKey(t *testing.T) {
func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
passwordKeyName := "prod-web-password" //nolint:gosec // G101: keyring key name used in a test, not secret material.
sudoKeyName := "prod-web-sudo" //nolint:gosec // G101: keyring key name used in a test, not secret material.
sshKeyName := "prod-web-login" //nolint:gosec // G101: keyring key name used in a test, not secret material.
err := SaveSettings(&Settings{
Key: "/keys/default.pem",
Hosts: []HostConfig{
{
Name: "prod-web",
Host: "10.0.0.5",
Port: "2222",
User: "root",
Key: "/keys/prod-web.pem",
PasswordKey: passwordKeyName,
Name: "prod-web",
Host: "10.0.0.5",
Port: "2222",
User: "root",
Key: "/keys/prod-web.pem",
SudoPasswordKey: sudoKeyName,
SSHPasswordKey: sshKeyName,
},
},
})
Expand All @@ -462,14 +464,31 @@ func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) {
if result["key_path"] != "/keys/prod-web.pem" {
t.Errorf("expected configured host key path, got %v", result["key_path"])
}
if result["sudo_key"] != passwordKeyName {
t.Errorf("expected configured password key, got %v", result["sudo_key"])
if result["sudo_key"] != sudoKeyName {
t.Errorf("expected configured sudo password key, got %v", result["sudo_key"])
}
if result["would_connect"] != true {
t.Errorf("expected real host test would connect, got %v", result["would_connect"])
}
// Host diagnostics may read only the typed SSH login password key.
if result["would_read_secret"] != true {
t.Errorf("expected real host test would read configured password key, got %v", result["would_read_secret"])
t.Errorf("expected real host test would read SSH password key, got %v", result["would_read_secret"])
}

// sudo-only hosts must not imply an SSH login secret read.
err = SaveSettings(&Settings{
Hosts: []HostConfig{{
Name: "sudo-only",
Host: "10.0.0.6",
SudoPasswordKey: sudoKeyName,
}},
})
if err != nil {
t.Fatalf("SaveSettings() error = %v", err)
}
sudoOnly := runDryRunJSON(t, []string{"sshx", "--host-test=sudo-only", "--dry-run", "--json"})
if sudoOnly["would_read_secret"] != false {
t.Errorf("expected sudo-only host test not to read secrets, got %v", sudoOnly["would_read_secret"])
}
}

Expand Down
Loading
Loading