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
15 changes: 10 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ jobs:
run: go mod download

- name: Run unit tests (cross-platform packages)
# Full-suite Windows enablement is tracked in issue #50: several
# pre-existing app/plugin/skillinstall/sshclient tests assume POSIX
# permission and symlink semantics. Packages listed here must stay
# green; grow this list as tests are ported.
run: go test -short ./cmd/... ./internal/execution/... ./internal/keyringstore/... ./internal/sqlsafe/... ./pkg/...
# Windows is a first-class target, so the CLI surface (internal/app),
# the SSH core (internal/sshclient), and the runtime-root resolver run
# here too. Tests resolve the home directory through setTestHome so
# USERPROFILE is honored, and POSIX permission assertions are guarded
# by runtime.GOOS.
#
# Still excluded (tracked in issue #50): internal/plugin and
# internal/skillinstall assert symlink and permission semantics that
# need Windows equivalents, and tests/e2e has not been ported.
run: go test -short ./cmd/... ./internal/app/... ./internal/execution/... ./internal/keyringstore/... ./internal/runtimepath/... ./internal/sqlsafe/... ./internal/sshclient/... ./pkg/...

- name: Vet
run: go vet ./...
Expand Down
26 changes: 20 additions & 6 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,18 @@ Notes:

### CI (`.github/workflows/`)

- `ci.yml`: **Test** (ubuntu + macOS, Go 1.25.13, `-race -cover`), **Lint**
(golangci-lint), **Security Scan** (`gosec` plus `govulncheck`), **Analyze**
(CodeQL, Go).
- `ci.yml`: **Test** (ubuntu + macOS, Go 1.25.13, `-race -cover`),
**Test (windows-latest)** (`-short`, build + vet), **Lint** (golangci-lint),
**Security Scan** (`gosec` plus `govulncheck`), **Analyze** (CodeQL, Go),
and **E2E** (ubuntu + macOS).
- Windows is a first-class target, so its job covers `cmd`, `internal/app`,
`internal/execution`, `internal/keyringstore`, `internal/runtimepath`,
`internal/sqlsafe`, `internal/sshclient`, and `pkg`. When adding a test that
touches the home directory, use the package's `setTestHome` helper rather
than `t.Setenv("HOME", …)`: Go reads `USERPROFILE` on Windows. Guard POSIX
permission assertions with `runtime.GOOS != "windows"`. `internal/plugin`,
`internal/skillinstall`, and `tests/e2e` are still excluded pending Windows
symlink/permission equivalents (issue #50).
- `release.yml`: builds release artifacts with Go 1.25.13 and bundles the matching
Agent skill in every archive.

Expand Down Expand Up @@ -281,10 +290,15 @@ tests.
true only when the remote command starts with `sudo`, matching the exact
form `sudoStdinCommand` can safely rewrite. Non-leading sudo inside shell
wrappers or pipelines is left untouched.
5. **Command safety checks.** Destructive patterns (`rm -rf /`, `mkfs`, `dd`,
5. **Command safety checks.** Destructive operations (`rm -rf /`, `mkfs`, `dd`,
fork bombs, `curl | sh`, critical file edits, shutdown/reboot) are blocked
unless `--force`/`-f` or `--no-safety-check` is given. Direct database
client execution (`psql`/`pgcli` in command position, including
unless `--force`/`-f` or `--no-safety-check` is given. Matching happens on
the token in **command position** after shell segmentation, never on the raw
command string: `last reboot -F`, `journalctl | grep -iE 'fail|halt'`, and
`iptables-save | grep -F ...` are reads and must stay allowed. A guardrail
that fires on reads trains the caller to pass `--force` reflexively, which
defeats its purpose — treat a new false positive as a bug. Direct database
client execution (`psql`/`pgcli`/`sqlite3` in command position, including
`docker exec`, `sudo -u`, `sh -c`, `kubectl exec`, and pipe wrappers) is
also blocked and redirected to `sshx sql`; availability probes such as
`which psql` and `psql --version` stay allowed. The validator is a
Expand Down
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.12.0] - 2026-08-28

### Changed

- Windows CI now runs the CLI surface (`internal/app`), the SSH core
(`internal/sshclient`), and `internal/runtimepath` in addition to the
previously covered packages, taking the Windows matrix from 91 to roughly 320
tests. Tests resolve the home directory through a portable helper so
`USERPROFILE` is honored, and POSIX permission assertions are guarded by
`runtime.GOOS`. `internal/plugin`, `internal/skillinstall`, and `tests/e2e`
remain excluded pending Windows symlink/permission equivalents (issue #50).

### Added

- `sshx_run` over MCP accepts `shell` for parity with the CLI `--shell`, and
script payloads sent over MCP follow their shebang like CLI payloads do.
- `sshx sql --docker=<container>` now reads that container's environment for the
database role and name, so a TimescaleDB/Postgres image whose `POSTGRES_USER`
is not `postgres` no longer fails with `role "postgres" does not exist`.
`--db` and `--db-user` become optional in this form. Discovery is best-effort:
a container that cannot be inspected or exposes no credentials falls back to
the client defaults, and passing `--db-user` or `--db-password-key` disables
it. `--db-cred-from` keeps its stricter contract and still requires a password.
- `sshx run` script payloads now honor the script's shebang. A
`#!/usr/bin/env bash` payload runs under `bash -s --` instead of being piped
to `sh`, so bash-only constructs (`set -o pipefail`, arrays, `[[ ]]`) work
instead of failing remotely with `Illegal option -o pipefail`. `--shell=NAME`
overrides the shebang; supported interpreters are `sh`, `bash`, `zsh`,
`dash`, `ksh`, and `ash`. A payload declaring any other interpreter (for
example `python3`) is now rejected locally as `error_kind: config` with no
connection, instead of being silently executed by `sh`. The selected
interpreter appears as `action.script_runner` in dry-run plans and results.
- Safety-check recall now covers recursive removal of critical system
directories (`/etc`, `/usr`, `/var`, …), `rm --no-preserve-root`,
`wipefs -a`, `chown -R ... /`, LVM `pvremove`/`vgremove`/`lvremove`,
`zpool|zfs destroy`, `dd of=/dev/<disk>`, `systemctl kexec`, and destructive
commands nested inside `docker exec` / `docker compose exec`.

### Fixed

- A missing remote database client is now reported as `error_kind: config`
naming the binary, instead of the opaque
`database operation failed during execute with status 127` that required
decoding a shell convention to understand.
- Command safety checks no longer match dangerous keywords anywhere in the raw
command string. The command line is split into shell segments and only the
token in **command position** is judged, following `sudo`/`env`/`timeout`
wrappers, `sh -c` payloads, and `docker exec` into the command that actually
runs. Read-only diagnostics such as `last reboot -F`,
`journalctl | grep -iE 'fail|halt'`, `iptables-save | grep -F ...`,
`curl ... | sha256sum`, `fdisk -l /dev/sda`, `parted /dev/sdb print`, and
bare `wipefs /dev/sdb` are no longer blocked. Replaying 49 commands that a
real workload had blocked shows 48 were false positives; only `rm -rf /`
remains blocked, alongside the unchanged guarded-SQL client redirects.
`iptables` flag matching is now case-sensitive so `-F`/`-X` (flush / delete
chain) are distinguished from `-f`/`-x` (fragment / exact).

## [0.11.0] - 2026-08-25

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,10 @@ For PostgreSQL running in a production container, execute the database clients
inside the container and resolve credentials from its environment:

```bash
# --docker alone reads the container environment for the role and database,
# so images whose POSTGRES_USER is not "postgres" work without --db-user.
sshx sql -h=prod --docker=pg-prod --json "SELECT count(*) FROM orders"

sshx sql -h=prod --docker=pg-prod \
--db-cred-from=docker:pg-prod --json \
"UPDATE users SET active=false WHERE id=42"
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support

| Version | Supported |
| -------- | ------------------ |
| 0.12.x | :white_check_mark: |
| 0.11.x | :white_check_mark: |
| 0.10.x | :white_check_mark: |
| < 0.10.0 | :x: |
| < 0.11.0 | :x: |

Security updates are provided for the latest minor release and the previous
minor release (N-1). Older lines do not receive patches; please upgrade.
Expand Down
1 change: 1 addition & 0 deletions docs/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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.
- The script's `#!` line selects the interpreter, so a `#!/usr/bin/env bash` payload keeps bash semantics (`set -o pipefail`, arrays, `[[ ]]`). Use `--shell=NAME` to override it. Supported: `sh`, `bash`, `zsh`, `dash`, `ksh`, `ash`; any other interpreter is rejected as `error_kind: config` without connecting. The choice appears as `action.script_runner`.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Claude Desktop / generic MCP client entry:

| Tool | Maps to | Notes |
| --- | --- | --- |
| `sshx_run` | `sshx run --json` | Selectors, command or byte-preserving script, bounded fan-out, dry-run, force + bypass_reason |
| `sshx_run` | `sshx run --json` | Selectors, command or byte-preserving script (shebang or `shell` selects the interpreter), bounded fan-out, dry-run, force + bypass_reason |
| `sshx_sql` | `sshx sql --json` | Guarded single-statement SQL via remote psql/sqlite3 |
| `sshx_apply` | `sshx apply --json` | Guarded single-file replace; accepts `from_path` or inline `content` |
| `sshx_inspect` | `sshx inspect --json` | Built-in capabilities and trusted local plugins |
Expand Down
13 changes: 12 additions & 1 deletion docs/security-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,18 @@ This boundary keeps password lookup, stdin injection, and audit metadata aligned

## Safety Checks Are Guardrails

`sshx` blocks common destructive patterns such as root deletion, disk formatting, shutdown or reboot commands, critical system file edits, fork bombs, and `curl | sh` style pipelines.
`sshx` blocks common destructive operations such as root deletion, disk formatting, shutdown or reboot commands, critical system file edits, fork bombs, and `curl | sh` style pipelines.

Matching applies to the command actually being executed, not to any occurrence of a dangerous word. The command line is split into shell segments and only the token in command position is judged, so read-only diagnostics stay allowed:

```bash
sshx -h=prod-web "last reboot -F | head -10" # allowed: reboot is an argument
sshx -h=prod-web "journalctl -u app | grep -iE 'fail|halt'" # allowed: halt is a grep pattern
sshx -h=prod-web "sudo iptables-save | grep -F 10.0.0.0/24" # allowed: a different binary
sshx -h=prod-web "sudo iptables -F" # blocked: flushes the ruleset
```

Wrappers are followed: `sudo`, `env`, `nohup`, `timeout`, `sh -c '...'`, and `docker exec <container> ...` are all resolved to the command they ultimately run.

That does not make untrusted commands safe. A command validator cannot understand every script, shell expansion, application-specific migration, or data-destruction path.

Expand Down
1 change: 1 addition & 0 deletions docs/zh/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sshx run --target=prod-web --script-file=./check.sh --dry-run --json

- 选择器只解析已配置主机;字面地址用 `--address=`,不能进入 group/tag 扩散。
- 脚本经 SSH stdin 原样传输,不经本地 `strings.Join` 拼装。
- 脚本的 `#!` 行决定解释器,`#!/usr/bin/env bash` 会真正用 bash 执行(`set -o pipefail`、数组、`[[ ]]` 都可用)。可用 `--shell=NAME` 覆盖。支持 `sh`、`bash`、`zsh`、`dash`、`ksh`、`ash`;其他解释器在本地就以 `error_kind: config` 拒绝,不建立连接。最终解释器体现在 `action.script_runner`。
- dry-run/结果暴露 payload SHA-256 与字节数,默认不回传脚本全文。
- 多主机 `--jsonl` 输出 `run_started` / `target_*` / `run_finished`。
- 多主机退出码:`0` 全成功,`1` 部分失败/跳过/不确定,`255` 请求级失败。
Expand Down
13 changes: 12 additions & 1 deletion docs/zh/security-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,18 @@ sshx -h=prod-web "echo sudo"

## 安全检查只是护栏

`sshx` 会拦截常见破坏性模式,例如删除根目录、格式化磁盘、关机重启、修改关键系统文件、fork bomb 和 `curl | sh` 这类管道。
`sshx` 会拦截常见破坏性操作,例如删除根目录、格式化磁盘、关机重启、修改关键系统文件、fork bomb 和 `curl | sh` 这类管道。

判定对象是**真正要执行的命令**,而不是命令串里出现的危险词。sshx 先做 shell 分段,只判断处于命令位的 token,因此只读诊断不会被误拦:

```bash
sshx -h=prod-web "last reboot -F | head -10" # 放行:reboot 是参数
sshx -h=prod-web "journalctl -u app | grep -iE 'fail|halt'" # 放行:halt 是 grep 模式
sshx -h=prod-web "sudo iptables-save | grep -F 10.0.0.0/24" # 放行:是另一个程序
sshx -h=prod-web "sudo iptables -F" # 拦截:清空规则链
```

包装器会被穿透解析:`sudo`、`env`、`nohup`、`timeout`、`sh -c '...'` 以及 `docker exec <容器> ...` 都会一路解析到最终执行的命令。

这并不代表不可信命令就安全了。命令校验器不可能理解所有脚本、shell 展开、应用迁移和业务数据删除路径。

Expand Down
16 changes: 8 additions & 8 deletions internal/app/agentmode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func TestClassifyError(t *testing.T) {
// before any network work, so it reports error_kind "blocked" (not "connect")
// even though the host is never reachable.
func TestRun_BlockedCommandShortCircuits(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
// 192.0.2.1 is RFC 5737 TEST-NET-1: if validation did not short-circuit,
// the dial would block instead of returning instantly.
result := runReportedJSON(t, []string{"sshx", "-h=192.0.2.1", "--json", "rm -rf /"})
Expand All @@ -146,7 +146,7 @@ func TestRun_BlockedCommandShortCircuits(t *testing.T) {
}

func TestRun_BlockedCommandJSONRedactsSecretLikeArguments(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
secretFragments := []string{"alpha", "bravo", "charlie", "delta"}
result := runReportedJSON(t, []string{
"sshx",
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestRun_JSONConfigFailuresDoNotConnect(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
result := runReportedJSON(t, tt.args)
if result["error_kind"] != "config" {
t.Fatalf("expected error_kind=config, got %v", result["error_kind"])
Expand Down Expand Up @@ -305,7 +305,7 @@ func TestEmitCommandJSONContracts(t *testing.T) {
}

func TestRun_DryRunJSONDoesNotConnect(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
result := runDryRunJSON(t, []string{"sshx", "-h=192.0.2.1", "--dry-run", "--json", "uptime"})

if result["dry_run"] != true {
Expand All @@ -329,7 +329,7 @@ func TestRun_DryRunJSONDoesNotConnect(t *testing.T) {
}

func TestRun_DryRunReportsBlockedCommand(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
result := runDryRunJSON(t, []string{"sshx", "-h=192.0.2.1", "--dry-run", "--json", "sudo rm -rf /"})

if result["valid"] != false {
Expand Down Expand Up @@ -357,7 +357,7 @@ func TestRun_DryRunReportsBlockedCommand(t *testing.T) {
}

func TestRun_DryRunMissingHostDoesNotPlanConnection(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
result := runDryRunJSON(t, []string{"sshx", "--dry-run", "--json", "uptime"})

if result["valid"] != false {
Expand All @@ -380,7 +380,7 @@ func TestRun_DryRunMissingHostDoesNotPlanConnection(t *testing.T) {

func TestRun_DryRunResolvesNamedHostAndSudoKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setTestHome(t, home)
passwordKeyName := "prod-web-sudo" //nolint:gosec // G101: keyring key name used in a test, not secret material.
err := SaveSettings(&Settings{
Key: "/keys/default.pem",
Expand Down Expand Up @@ -429,7 +429,7 @@ func TestRun_DryRunResolvesNamedHostAndSudoKey(t *testing.T) {

func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setTestHome(t, home)
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{
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func TestRun_ArgumentParsing(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
// Suppress output
oldStdout := os.Stdout
oldStderr := os.Stderr
Expand Down
12 changes: 7 additions & 5 deletions internal/app/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
Expand All @@ -15,7 +16,7 @@ import (
)

func TestRun_BlockedCommandWritesRedactedAuditEvent(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
auditDir := t.TempDir()
command := "sudo rm -rf / password=orange --token purple" //nolint:gosec // test verifies redaction of credential-like arguments.

Expand Down Expand Up @@ -193,7 +194,7 @@ func TestSQLAuditUsesRedactedStatementAndDigest(t *testing.T) {
}

func TestRun_DryRunDoesNotWriteAuditEvent(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
auditDir := filepath.Join(t.TempDir(), "audit")
result := runDryRunJSON(t, []string{"sshx", "-h=192.0.2.1", "--audit-output=" + auditDir, "--dry-run", "--json", "uptime"})

Expand All @@ -206,7 +207,7 @@ func TestRun_DryRunDoesNotWriteAuditEvent(t *testing.T) {
}

func TestAuditRecorderRefreshRecordsExecutionContract(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
config := &sshclient.Config{
AuditEnabled: true,
Host: "prod-web",
Expand Down Expand Up @@ -336,7 +337,7 @@ func TestAuditEffectFlagsByModeAndAction(t *testing.T) {

func TestWriteAuditEventUsesJSONLWithPrivatePermissions(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setTestHome(t, home)
config := &sshclient.Config{AuditEnabled: true}
event := auditEvent{
SchemaVersion: auditSchemaVersion,
Expand All @@ -357,7 +358,8 @@ func TestWriteAuditEventUsesJSONLWithPrivatePermissions(t *testing.T) {
if err != nil {
t.Fatalf("expected audit file at %s: %v", auditPath, err)
}
if info.Mode().Perm() != 0o600 {
// Windows has no POSIX permission bits; Go reports 0666/0777 there.
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("expected audit file mode 0600, got %v", info.Mode().Perm())
}

Expand Down
2 changes: 2 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ func parseRunArgs(config *sshclient.Config, args []string) {
case arg == "--script-stdin":
config.ScriptStdin = true
config.RunActionKind = "script"
case strings.HasPrefix(arg, "--shell="):
config.ScriptShell = strings.SplitN(arg, "=", 2)[1]
case arg == "--sudo":
config.RunUseSudo = true
case strings.HasPrefix(arg, "--max-output-bytes="):
Expand Down
Loading
Loading