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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,9 @@ jobs:
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: latest
# Pinned deliberately: `latest` silently changed the linter and broke
# this gate (see issue #82). Bump this together with .golangci.yml.
version: v2.13.2
args: --timeout=5m

security:
Expand Down
86 changes: 76 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- `sshx text` reads remote files through a pipelined SFTP path: read-ahead
aperture plus `UseConcurrentReads`, so the SFTP layer keeps multiple requests
in flight for one file instead of one round trip per read. On the reporting
host the same 8 MiB window went from a median 83.0 s to 26.5 s (88.0/77.9 s →
23.8/29.1 s, alternating runs, identical bytes and lines scanned). `--max-scan-bytes`
still bounds both the scan and the read-ahead, and the truncation probe reads
exactly one byte past the budget.

### Added

- `sshx text` narrates scan progress on stderr after a short grace period
Expand All @@ -38,13 +28,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a leading `sudo`, so it announces the boundary before connecting and explains
the refusal afterwards, suggesting `sudo sh -c "<command>"`. The auto-fill
scope is unchanged.
- Per-verb help: every subcommand now answers `sshx <verb> --help` with its own
usage document instead of rejecting `--help` as an unknown option. `--help
--json` emits the same blocks as an `sshx.help.v1` document (`sshx text
--help --json` keeps its structured `sshx.text.help.v1` document), and the
help text is defined once and shared with the global `sshx --help` surface.
- `--quiet` (alias `--no-notices`): suppresses human notices on stderr
(deprecation warnings, policy-block mirrors, sudo-boundary hints, scan
progress, narration) so a caller that merges the streams
(`2>&1`) under `--json` still reads exactly one parseable document. stdout,
the exit code, and the JSON result are unchanged. Like `--help`, it is
recognized in option position in any order, so it works before or after other
sshx options and never reaches the remote command.
- `sshx sql --statement-file=PATH` and statement input on stdin: a query no
longer has to be assembled as a shell string. `sshx sql` also accepts a
statement that opens with a SQL comment (`-- header`) as statement text
instead of rejecting it as an unknown option. Reading stdin waits for EOF
(like `psql`), so a caller whose stdin pipe stays open should pass
`--statement-file` instead.
- `sshx plugin install <dir> [--replace] [--trust]`: provision an existing local
plugin directory through the audited CLI instead of hand-placing files under
the runtime plugin root. The source is staged with sshx's own modes, validated
through the executor's loader before publishing, and `--trust` records the
published digest in the same step. `--replace` preserves the previous plugin
as a backup, symlinks and non-regular entries are refused, and the copy is
bounded (8MiB, 128 files).

### Changed

- `sshx text` reads remote files through a pipelined SFTP path: read-ahead
aperture plus `UseConcurrentReads`, so the SFTP layer keeps multiple requests
in flight for one file instead of one round trip per read. On the reporting
host the same 8 MiB window went from a median 83.0 s to 26.5 s (88.0/77.9 s →
23.8/29.1 s, alternating runs, identical bytes and lines scanned). `--max-scan-bytes`
still bounds both the scan and the read-ahead, and the truncation probe reads
exactly one byte past the budget.

- `sshx plugin list` groups built-in capabilities and local plugins and always
names the local plugin root, so "no local plugins installed" is visible
instead of inferred; local entries report provenance, trust, validity, and
digest, and `plugin show`/`trust`/`install` report the same state line. A
staging directory left by an interrupted install is skipped instead of being
reported as an invalid plugin, and a publication that cannot be renamed swaps
the previous plugin back in (or reports where the recovery copy was kept).
- Compatibility mode (`sshx -h=<host> ...`) now accepts the documented
`--ssh-password-key=KEY` option instead of forwarding it as part of the remote
command, and rejects an unrecognized option instead of forwarding it, naming
the offending token and suggesting the intended option when one is close. The guessed
`--local`/`--remote` transfer options name the real surface
(`--upload=<local> --to=<remote>`), and a missing upload/download destination
names `--to` explicitly. Remote command arguments after the first command
token, and after `--`, are unchanged.
- A missing plugin, `--list=`, `--mkdir=`, `--rm=`, `--upload=` or
`--download=` now names the searched directory or the option that supplies the
missing value (`remote path is required` / `local path is required` diagnosed
the wrong cause).

### Fixed

- `sshx text` no longer looks like a hang: a 60+ second SFTP window used to emit
nothing at all, and a budget-limited scan returned partial results without
saying so.

- `sshx run --target=<name>` resolves the sudo keyring reference per host, the
same way single-target verbs do: an explicit `-pk` still wins, but the
built-in default (`master`) no longer shadows a host's configured
`sudo_password_key`. The plan, the SSH client, the keyring lookup, and the
audit trail all resolve the same reference, so the plan reports the key that
will be used and each target's audit event names the credential it used (the
run summary records only a caller-level choice).
- `sshx apply` removes its staging temp, publication temp, and unverified backup
with an absolute remover or POSIX `unlink` before falling back to `PATH rm`, so
a host whose `rm` is a trash-move wrapper no longer collects leaked copies of
the applied payload. Cleanup success now means the path is gone, and an
artifact that cannot be removed still reports `cleanup_pending` with exit 4.
- `TestApplySudoScriptEvidenceAndCleanup` no longer pipes the generated
privileged script through the child's stdin or captures its output through
`os/exec` copier goroutines; the fixture runs the script from a file with
file-backed streams and reports a truncated report readably instead of
panicking on a nil pointer.
- The CI `Lint` gate is pinned to golangci-lint `v2.13.2` and `.golangci.yml`
uses the v2 `linters.exclusions.rules` schema, so an upstream release or the
v1-era key can no longer fail the job before any Go file is analysed.

## [0.17.0] - 2026-09-16

### Added
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ replacement.
### `--json` structured output

Add `--json` to get a single JSON object on stdout (diagnostics still go to
stderr, so stdout stays pure):
stderr, so stdout stays pure). Human notices such as deprecation warnings and
narration never touch stdout, and `--quiet` suppresses them on stderr, so a
caller that merges the streams (`2>&1`) still reads one parseable document:

```bash
sshx -h=prod-web --json "systemctl is-active nginx"
Expand Down Expand Up @@ -434,7 +436,11 @@ Use `sshx sql` instead of sending raw `psql` or `sqlite3` commands through
`sshx run`. It accepts exactly one statement, classifies it locally, blocks
unbounded or unsupported forms, backs up affected data, and records a
structured audit event. Direct `psql`/`pgcli`/`sqlite3` invocations in
run/command mode are blocked.
run/command mode are blocked. The statement may be a positional argument,
everything after `--`, a local file (`--statement-file=PATH`), or piped stdin;
a statement that opens with a SQL comment is statement text, not an option.
Reading stdin waits for EOF, so close stdin (or use `--statement-file`) when
another process holds the pipe open.

For PostgreSQL, sshx runs `EXPLAIN (FORMAT JSON)` before DML. Psql backslash
commands, data-modifying CTE bodies, `EXPLAIN ANALYZE`, `SELECT INTO`, `CALL`,
Expand All @@ -454,6 +460,10 @@ sshx sql -h=prod-db --db=app --dry-run --json \
sshx sql -h=prod-db --db=app --db-user=app \
--db-password-key=app-db --json \
"UPDATE users SET active=false WHERE id=42"

# Hand over a .sql file, or pipe the statement in
sshx sql -h=prod-db --db=app --json --statement-file=./query.sql
printf '%s' 'SELECT count(*) FROM users' | sshx sql -h=prod-db --db=app --json
```

`UPDATE`/`DELETE` without a top-level `WHERE` requires
Expand Down Expand Up @@ -568,6 +578,14 @@ sshx plugin trust docker.environment --json
sshx inspect -h=prod-web docker.environment --json
```

An existing plugin directory is provisioned through the CLI instead of by hand:
`sshx plugin install <dir>` stages the source with sshx's own modes, validates it
through the same loader the executor uses, publishes it only when it is valid,
and `--trust` records the digest in the same step (`--replace` keeps the previous
plugin as a backup). `sshx plugin list` groups built-in capabilities and local
plugins and always names the local plugin root, so "none installed" is visible,
and a missing plugin names the directory that was searched.

New and edited plugins are untrusted until their current manifest/collector/schema
digest is explicitly trusted. `inspect` checks that trust before opening SSH,
streams the collector through stdin for that session only, validates one JSON
Expand Down
8 changes: 7 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ sshx: block reason: ⚠️ Dangerous command blocked | ... | Reason: Direct Pos

### `--json` 结构化输出

加上 `--json` 即可在 stdout 得到单个 JSON 对象(诊断日志仍走 stderr,保证 stdout 纯净):
加上 `--json` 即可在 stdout 得到单个 JSON 对象(诊断日志仍走 stderr,保证 stdout 纯净)。人工notice(弃用警告、进度叙述)只会出现在 stderr,`--quiet` 可将其静默:这样把两个流合并(`2>&1`)的调用方在成功与失败路径下都只会读到一份可解析文档:

```bash
sshx -h=prod-web --json "systemctl is-active nginx"
Expand Down Expand Up @@ -420,6 +420,12 @@ sshx plugin trust docker.environment --json
sshx inspect -h=prod-web docker.environment --json
```

已存在的插件目录请通过 CLI 安装,不要手工放置文件:`sshx plugin install <dir>`
会用 sshx 自己的权限写入暂存副本、用执行器同一套 loader 校验、只有校验通过才发布;
`--trust` 在同一步记录摘要,`--replace` 保留旧插件作为备份。`sshx plugin list`
会区分内置能力与本地插件并始终打印本地插件根目录(因此"未安装"是可见状态),
插件缺失时的报错也会指出实际搜索的目录。

新建或修改后的插件默认不可信。`plugin trust` 显式记录 manifest、collector
和 schema 的当前摘要;`inspect` 在联网前检查摘要信任,然后只在本次 SSH
会话中通过 stdin 临时执行采集器,校验唯一 JSON 输出并脱敏。插件信任不是
Expand Down
18 changes: 17 additions & 1 deletion docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,23 @@ not be introduced for an existing invocation shape.
- A breaking change requires a new schema (`sshx.result.v2`, …) and an N-1
support window: the previous schema remains emitted or accepted until the
next major sshx release after the new schema ships.
- `--json` stdout stays a machine document. Human logs belong on stderr.
- `--json` stdout stays a machine document. Human logs belong on stderr, and
sshx never writes human text to stdout, including on failure.
- stderr carries human notices (deprecation warnings, narration, progress) and,
outside `--json`, the diagnostic for a failed invocation. `--quiet`
(`--no-notices`) suppresses the notices; with it, a caller that merges the
streams (`2>&1`) under `--json` still reads exactly one document, in both the
success and the failure path. Merging stderr without `--quiet` is not
supported for parsing.
- Per-verb discovery is part of the contract: every subcommand answers
`sshx <verb> --help`, and `sshx <verb> --help --json` emits the same blocks as
an `sshx.help.v1` document (`sshx text --help --json` keeps its structured
`sshx.text.help.v1` document).
- `--help` and `--quiet` are recognized in option position, in any order:
`sshx -h=<host> -p=22 --help` prints the global usage without resolving a host,
connecting, or touching the trust store. Once the remote command, SQL
statement, or `--` separator starts, a later `--help`/`--quiet` belongs to the
payload.
- MCP tools return the CLI JSON verbatim. MCP does not grow a parallel schema.

## Additive execution hardening
Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Agent / 自动化 / 人类运维者

- **sshx 本地插件生命周期**

Agent 可通过 `sshx plugin create` 在 `~/.sshx/plugins/`(或 `$SSHX_HOME/plugins/`)创建 Docker、Nginx 或自定义应用探测插件,并完成 list/show/validate/test/trust/remove。插件脚本不由 Agent skill 维护;摘要变化会使信任失效。证据:`internal/app/plugin.go`、`internal/plugin/`、`tests/e2e/inspect_plugin_e2e_test.go`。
Agent 可通过 `sshx plugin create` 在 `~/.sshx/plugins/`(或 `$SSHX_HOME/plugins/`)创建 Docker、Nginx 或自定义应用探测插件,也可用 `sshx plugin install <dir>` 在 CLI 内完成已有插件目录的暂存、校验、发布与 `--trust`,并完成 list/show/validate/test/trust/remove。`plugin list` 区分内置能力与本地插件并始终打印本地插件根目录;插件缺失时报错指出实际搜索目录。插件脚本不由 Agent skill 维护;摘要变化会使信任失效。证据:`internal/app/plugin.go`、`internal/plugin/`、`tests/e2e/inspect_plugin_e2e_test.go`。

- **有界远端观察快照**

Expand Down Expand Up @@ -255,7 +255,7 @@ issue #71 的新增边界、验证状态及外部前提单列在后面的证据
| host-key 校验 | 高 | 是,信任状态 | 可能修改 `known_hosts` | ✅ 显式信任后严格复用 | ✅ 未知/变更 key | ✅ strict/accept-unknown | ✅ 首次写入后重新严格连接 | `tests/e2e/cli_e2e_test.go` |
| 危险动作阻断与显式绕过 | 高 | 是 | 否,仅控制执行准入 | ✅ 显式 `--force` | ✅ 默认阻断且零连接 | ✅ 默认阻断/显式绕过 | 不适用:策略门本身不修改状态 | `tests/e2e/cli_e2e_test.go` |
| 本地结构化审计 | 高 | 否 | 是,本地 | ✅ | ✅ 不可写目标可观测 | 不适用:本地调用者同权 | ✅ 修复目标后单事件写入 | `tests/e2e/host_audit_e2e_test.go` |
| 本地探测插件生命周期 | 高 | 本地调用者权限 | 是,本地 | ✅ create/list/show/validate/test/trust/remove | ✅ 路径逃逸、重复创建、manifest/entrypoint/schema/fixture 分类失败 | ✅ 私有目录/文件权限 | ✅ replace/remove 保留可恢复备份 | `tests/e2e/inspect_plugin_e2e_test.go` |
| 本地探测插件生命周期 | 高 | 本地调用者权限 | 是,本地 | ✅ create/install/list/show/validate/test/trust/remove | ✅ 路径逃逸、重复创建、symlink/非普通文件源、安装前校验失败、manifest/entrypoint/schema/fixture 分类失败 | ✅ 私有目录/文件权限 | ✅ replace/remove 保留可恢复备份;安装校验失败不触碰已发布插件 | `tests/e2e/inspect_plugin_e2e_test.go`、`internal/plugin/install_test.go` |
| Agent Skill 安装 | 高 | 本地调用者权限 | 是,本地 Agent 信任目录 | ✅ 编译后二进制离线安装/幂等复用 | ✅ 内容冲突与 symlink 目标拒绝 | ✅ 默认目录/显式目录 | ✅ 冲突不覆盖,显式 force 后恢复官方版本 | `tests/e2e/skill_e2e_test.go` |
| 单主机探测与内置基线 | 高 | 是 | 否,cache off | ✅ 自定义插件与 `system.baseline` | ✅ 未信任、污染/超限输出、超时、非零退出、不支持平台 | ✅ operator/reader/sudo-required | 不适用:不修改远端状态 | `tests/e2e/inspect_plugin_e2e_test.go`、`tests/e2e/keyring_e2e_test.go` |
| 远端观察缓存 | 高 | 是 | 是,远端 JSON | ✅ 冷写入/热复用/并发原子替换 | ✅ TTL/boot ID、格式、大小、属主、权限、symlink、只读端 | ✅ 可写/只读 SFTP | ✅ 失败写入保留原有效快照 | `tests/e2e/inspect_plugin_e2e_test.go` |
Expand Down
53 changes: 53 additions & 0 deletions internal/app/agentmode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,59 @@ func TestRun_DryRunResolvesNamedHostAndSudoKey(t *testing.T) {
}
}

// `sshx run --target=` resolves the sudo keyring reference per host, exactly as
// the single-target verbs do: the built-in default key must not shadow the
// host's sudo_password_key, and an explicit -pk still wins (issue #78).
func TestRun_DryRunTargetResolvesHostSudoKey(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
sudoKeyName := "prod-web-sudo" //nolint:gosec // G101: keyring key name used in a test, not secret material.
err := SaveSettings(&Settings{
Hosts: []HostConfig{{
Name: "prod-web",
Host: "10.0.0.5",
Port: "2222",
User: "root",
SudoPasswordKey: sudoKeyName,
}},
})
if err != nil {
t.Fatalf("SaveSettings() error = %v", err)
}

targetSudoKey := func(t *testing.T, args []string) string {
t.Helper()
result := runDryRunJSON(t, args)
plan, ok := result["plan"].(map[string]any)
if !ok {
t.Fatalf("expected nested plan object, got %T", result["plan"])
}
targets, ok := plan["targets"].([]any)
if !ok || len(targets) != 1 {
t.Fatalf("expected one planned target, got %v", plan["targets"])
}
target, ok := targets[0].(map[string]any)
if !ok {
t.Fatalf("expected target object, got %T", targets[0])
}
key, ok := target["sudo_key"].(string)
if !ok {
t.Fatalf("planned target has no sudo_key string: %v", target)
}
return key
}

configured := targetSudoKey(t, []string{"sshx", "run", "--target=prod-web", "--dry-run", "--json", "--", "sudo whoami"})
if configured != sudoKeyName {
t.Errorf("expected the host's sudo key %q in the plan, got %q", sudoKeyName, configured)
}

override := targetSudoKey(t, []string{"sshx", "run", "--target=prod-web", "-pk=explicit-sudo", "--dry-run", "--json", "--", "sudo whoami"})
if override != "explicit-sudo" {
t.Errorf("expected an explicit -pk to win, got %q", override)
}
}

func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
Expand Down
Loading
Loading