diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf39f2..538538d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a46ab..25d011a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -38,6 +28,61 @@ 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 ""`. The auto-fill scope is unchanged. +- Per-verb help: every subcommand now answers `sshx --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 [--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= ...`) 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= --to=`), 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 @@ -45,6 +90,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 nothing at all, and a budget-limited scan returned partial results without saying so. +- `sshx run --target=` 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 diff --git a/README.md b/README.md index 5b753c2..4696b89 100644 --- a/README.md +++ b/README.md @@ -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" @@ -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`, @@ -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 @@ -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 ` 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 diff --git a/README_CN.md b/README_CN.md index a5b4920..0de6d00 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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" @@ -420,6 +420,12 @@ sshx plugin trust docker.environment --json sshx inspect -h=prod-web docker.environment --json ``` +已存在的插件目录请通过 CLI 安装,不要手工放置文件:`sshx plugin install ` +会用 sshx 自己的权限写入暂存副本、用执行器同一套 loader 校验、只有校验通过才发布; +`--trust` 在同一步记录摘要,`--replace` 保留旧插件作为备份。`sshx plugin list` +会区分内置能力与本地插件并始终打印本地插件根目录(因此"未安装"是可见状态), +插件缺失时的报错也会指出实际搜索的目录。 + 新建或修改后的插件默认不可信。`plugin trust` 显式记录 manifest、collector 和 schema 的当前摘要;`inspect` 在联网前检查摘要信任,然后只在本次 SSH 会话中通过 stdin 临时执行采集器,校验唯一 JSON 输出并脱敏。插件信任不是 diff --git a/docs/contract.md b/docs/contract.md index 4382161..f59af3f 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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 --help`, and `sshx --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= -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index c66f0fe..5bccf2e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 ` 在 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`。 - **有界远端观察快照** @@ -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` | diff --git a/internal/app/agentmode_test.go b/internal/app/agentmode_test.go index efb7535..6d88278 100644 --- a/internal/app/agentmode_test.go +++ b/internal/app/agentmode_test.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 651788a..6a60fef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -86,6 +86,11 @@ func RunContext(ctx context.Context, args []string) (err error) { config := ParseArgs(args) config.Context = ctx config.ExecutionID = execution.NewRunID() + if config.Quiet { + // Notices are advisory narration. --quiet keeps stderr free of them so a + // caller that merges streams under --json still reads one document. + logger.GetLogger().SetLevel(logger.LogLevelError) + } if config.ArgumentError != "" { if config.Mode == "run" { return reportRunRequestFailure(config, nil, fmt.Errorf("%w: %s", execution.ErrConfig, config.ArgumentError)) @@ -106,8 +111,14 @@ func RunContext(ctx context.Context, args []string) (err error) { if config.Mode == "mcp" { return RunMCPServerContext(ctx) } - if config.Mode == "text" && config.TextHelp { - return HandleTextHelp(config) + // A usage request is answered before any mode runs: `sshx --help` prints + // that verb's document and `sshx --help` prints the global surface. + if config.ShowUsage { + PrintUsage() + return nil + } + if config.HelpVerb != "" { + return PrintVerbUsage(config) } audit := newAuditRecorder(config) @@ -382,7 +393,7 @@ func runCommand(client *sshclient.SSHClient, config *sshclient.Config, audit *au res, execErr := client.RunCommand(config.JSONOutput) dur := time.Since(start) audit.recordCommandResult(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr) - reportSudoPromptFailure(os.Stderr, config.Command, res.Stderr+res.Stdout) + reportSudoPromptFailure(noticeWriter(config), config.Command, res.Stderr+res.Stdout) if config.JSONOutput { if outputErr := emitCommandJSON(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr); outputErr != nil { @@ -590,7 +601,7 @@ func resolveHostFromSettings(config *sshclient.Config) error { // Use configured sudo password key if available (legacy password_key is sudo-only). sudoKey := hostConfig.EffectiveSudoPasswordKey() - if sudoKey != "" && config.SudoKey == sshclient.DefaultSudoKey { + if sudoKey != "" && !sudoKeyChosen(config) { config.SudoKey = sudoKey logger.GetLogger().Success("Using sudo password key: %s", sudoKey) } diff --git a/internal/app/audit.go b/internal/app/audit.go index 101d691..876df35 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -176,6 +176,9 @@ type auditRecorder struct { completed bool persisted bool persistenceErr error + // sudoKeyResolved marks an event whose sudo key was resolved per target, so + // finish() does not overwrite it with the caller-level value. + sudoKeyResolved bool } var ( @@ -531,7 +534,9 @@ func (r *auditRecorder) refresh(config *sshclient.Config) { r.event.UsesSudo = config.LoginUseSudo r.event.Command = "" } - r.event.SudoKey = config.SudoKey + if !r.sudoKeyResolved { + r.event.SudoKey = config.SudoKey + } if config.Timeout > 0 { r.event.Timeout = config.Timeout.String() } diff --git a/internal/app/cli_surface_test.go b/internal/app/cli_surface_test.go new file mode 100644 index 0000000..2cce405 --- /dev/null +++ b/internal/app/cli_surface_test.go @@ -0,0 +1,202 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Every subcommand answers `sshx --help` with its own usage document +// instead of rejecting --help as an unknown option. +func TestParseArgsVerbHelp(t *testing.T) { + for _, verb := range helpVerbs { + t.Run(verb, func(t *testing.T) { + config := ParseArgs([]string{"sshx", verb, "--help"}) + require.Empty(t, config.ArgumentError) + require.Equal(t, verb, config.HelpVerb) + require.False(t, config.JSONOutput) + }) + } + + jsonHelp := ParseArgs([]string{"sshx", "apply", "--help", "--json"}) + require.Equal(t, "apply", jsonHelp.HelpVerb) + require.True(t, jsonHelp.JSONOutput) +} + +// A usage request is answered in every option position, including after other +// sshx options in compatibility mode: it must never fall through to a connection +// or a trust-store write, and it must never be mistaken for an unknown option. +func TestParseArgsHelpInEveryOptionPosition(t *testing.T) { + for _, args := range [][]string{ + {"sshx", "-h=host", "--help"}, + {"sshx", "-h=host", "-p=1", "--json", "--help"}, + {"sshx", "--timeout=5s", "--help"}, + {"sshx", "-f", "--help"}, + {"sshx", "--quiet", "--help"}, + {"sshx", "--help"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + config := ParseArgs(args) + require.Empty(t, config.ArgumentError) + require.Empty(t, config.HelpVerb, "compatibility mode answers with the global usage") + require.True(t, config.ShowUsage) + require.NotEqual(t, "sftp", config.Mode) + }) + } + + // A subcommand answers with its own document in the same positions. + apply := ParseArgs([]string{"sshx", "apply", "-h=host", "--path=/a", "--from=/b", "--help"}) + require.Equal(t, "apply", apply.HelpVerb) + require.Empty(t, apply.ArgumentError) +} + +// The notice flag is accepted in first position too; a first-position option is +// an sshx option, not a remote command. +func TestParseArgsQuietFlagInFirstPosition(t *testing.T) { + for _, args := range [][]string{ + {"sshx", "--quiet", "-h=host", "uptime"}, + {"sshx", "--no-notices", "-h=host", "uptime"}, + {"sshx", "--quiet", "run", "--target=prod", "--", "true"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + config := ParseArgs(args) + require.Empty(t, config.ArgumentError) + require.True(t, config.Quiet) + }) + } +} + +// --help is an sshx option only while the payload has not started: once the +// remote command or SQL statement begins, the token belongs to that payload. +func TestParseArgsHelpStopsAtPayload(t *testing.T) { + run := ParseArgs([]string{"sshx", "run", "--target=prod", "--", "--help"}) + require.Empty(t, run.HelpVerb) + require.Equal(t, "--help", run.Command) + + sql := ParseArgs([]string{"sshx", "sql", "-h=db", "--db=app", "select 1", "--help"}) + require.Empty(t, sql.HelpVerb) + require.Equal(t, "select 1 --help", sql.SQLStatement) + + compat := ParseArgs([]string{"sshx", "-h=host", "grep", "--help", "x"}) + require.Empty(t, compat.HelpVerb) + require.Equal(t, "grep --help x", compat.Command) +} + +// Compatibility mode rejects an option-shaped token it does not know. Before +// this rule the token was forwarded as part of the remote command, so a typo +// ran with defaults and the failure named the wrong cause. +func TestCompatModeRejectsUnknownOptions(t *testing.T) { + tests := []struct { + name string + args []string + message string + }{ + {"unknown long option", []string{"-h=host", "--bogus-flag=1"}, `unknown option "--bogus-flag=1"`}, + {"near miss suggests the option", []string{"-h=host", "--dwnload=/tmp/x"}, `did you mean "--download"`}, + {"guessed transfer option names the real surface", []string{"--local=/tmp/x", "--remote=/tmp/y"}, "--upload="}, + {"unknown short option", []string{"-h=host", "-j=x"}, `unknown option "-j=x"`}, + {"short near miss suggests the option", []string{"-h=host", "-pj=x", "uptime"}, `did you mean "-pk"`}, + {"bare -h is not a host selector", []string{"-h", "host", "uptime"}, `unknown option "-h"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := ParseArgs(append([]string{"sshx"}, test.args...)) + require.Contains(t, config.ArgumentError, "unknown option") + require.Contains(t, config.ArgumentError, test.message) + }) + } +} + +// Option position ends where the payload starts: everything after the first +// command token (or the -- separator) belongs to the remote command. +func TestCompatModeKeepsPayloadTokens(t *testing.T) { + payload := ParseArgs([]string{"sshx", "-h=host", "grep", "-v", "foo"}) + require.Empty(t, payload.ArgumentError) + require.Equal(t, "grep -v foo", payload.Command) + + afterSeparator := ParseArgs([]string{"sshx", "-h=host", "--", "-la"}) + require.Empty(t, afterSeparator.ArgumentError) + require.Equal(t, "-la", afterSeparator.Command) + + sftp := ParseArgs([]string{"sshx", "-h=host", "--upload=/tmp/x", "--to=/tmp/y"}) + require.Empty(t, sftp.ArgumentError) + require.Equal(t, "sftp", sftp.Mode) +} + +// --quiet suppresses human notices without changing the parsed payload, and it +// is likewise recognized in option position only. +func TestParseArgsQuietFlag(t *testing.T) { + compat := ParseArgs([]string{"sshx", "-h=host", "--quiet", "uptime"}) + require.True(t, compat.Quiet) + require.Equal(t, "uptime", compat.Command) + + alias := ParseArgs([]string{"sshx", "run", "--no-notices", "--target=prod", "--", "true"}) + require.True(t, alias.Quiet) + require.Equal(t, []string{"prod"}, alias.RunTargets) + + verb := ParseArgs([]string{"sshx", "apply", "-h=host", "--quiet", "--path=/a", "--from=/b"}) + require.True(t, verb.Quiet) + require.Empty(t, verb.ArgumentError) + + payload := ParseArgs([]string{"sshx", "-h=host", "grep", "--quiet", "x"}) + require.False(t, payload.Quiet) + require.Equal(t, "grep --quiet x", payload.Command) +} + +// A statement that opens with a comment cannot be an option token, so sql takes +// it as statement text; option-shaped typos are still rejected. +func TestParseArgsSQLStatementSources(t *testing.T) { + comment := ParseArgs([]string{"sshx", "sql", "-h=db", "--db=app", "-- SELECT 1"}) + require.Empty(t, comment.ArgumentError) + require.Equal(t, "-- SELECT 1", comment.SQLStatement) + + multiLine := ParseArgs([]string{"sshx", "sql", "-h=db", "--db=app", "-- header\nselect 1;\n"}) + require.Empty(t, multiLine.ArgumentError) + require.Equal(t, "-- header\nselect 1;", multiLine.SQLStatement) + + path := filepath.Join(t.TempDir(), "query.sql") + require.NoError(t, os.WriteFile(path, []byte("-- header\nselect 2;\n"), 0o600)) + fromFile := ParseArgs([]string{"sshx", "sql", "-h=db", "--db=app", "--statement-file=" + path}) + require.Empty(t, fromFile.ArgumentError) + require.Equal(t, "-- header\nselect 2;", fromFile.SQLStatement) + + conflict := ParseArgs([]string{"sshx", "sql", "-h=db", "--db=app", "--statement-file=" + path, "select 3"}) + require.Contains(t, conflict.ArgumentError, "--statement-file cannot be combined") + + typo := ParseArgs([]string{"sshx", "sql", "-h=db", "--dbb=app", "select 1"}) + require.Contains(t, typo.ArgumentError, `did you mean "--db"`) + + missing := ParseArgs([]string{"sshx", "sql", "-h=db", "--statement-file=/nonexistent/query.sql"}) + require.Contains(t, missing.ArgumentError, "read --statement-file") +} + +// The suggestion lists must describe options the parser really accepts, so a +// typo never points at a name that does not exist. +func TestCompatOptionNamesAreRecognized(t *testing.T) { + for _, name := range compatOptionNames { + t.Run(name, func(t *testing.T) { + for _, arg := range []string{name, name + "=x"} { + if !strings.Contains(ParseArgs([]string{"sshx", "-h=host", arg}).ArgumentError, "unknown option") { + return + } + } + t.Fatalf("compatOptionNames entry %q is not parsed", name) + }) + } +} + +func TestSQLOptionNamesAreRecognized(t *testing.T) { + for _, name := range sqlOptionNames { + t.Run(name, func(t *testing.T) { + for _, arg := range []string{name, name + "=x"} { + if !strings.Contains(ParseArgs([]string{"sshx", "sql", "-h=db", arg, "select 1"}).ArgumentError, "unknown sql option") { + return + } + } + t.Fatalf("sqlOptionNames entry %q is not parsed", name) + }) + } +} diff --git a/internal/app/config.go b/internal/app/config.go index d9a201b..cb10551 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "io" "os" "strconv" "strings" @@ -76,6 +77,23 @@ func applySudoKeyFlag(config *sshclient.Config, arg string) bool { } } +// sudoKeyChosen reports whether the caller chose the sudo keyring reference: +// -pk/--password-key/--sudo-password-key, or a non-default SSH_SUDO_KEY. The +// built-in default ("master") is not a choice, so a host's configured +// sudo_password_key applies until the caller overrides it. +func sudoKeyChosen(config *sshclient.Config) bool { + return config.SudoKeySet || config.SudoKey != sshclient.DefaultSudoKey +} + +// sudoKeyChoice returns the caller's explicit sudo key, or "" when the key is +// still the built-in default. +func sudoKeyChoice(config *sshclient.Config) string { + if sudoKeyChosen(config) { + return config.SudoKey + } + return "" +} + func applyLifecycleFlag(config *sshclient.Config, arg string) bool { key, value, found := strings.Cut(arg, "=") if !found { @@ -102,6 +120,150 @@ func applyLifecycleFlag(config *sshclient.Config, arg string) bool { return true } +// scanVerbFlags scans sshx's own options in a verb invocation before the +// payload starts. It stops at the `--` separator and, for verbs whose first +// positional token begins a remote payload (compatibility mode, run, sql, ros), +// at that token: from there on every argument belongs to the payload, including +// tokens that look like sshx flags (see AGENT.md "Boundary Contracts"). +// +// It returns the arguments with the notice flags removed, plus the requested +// --help / --json / --quiet state. --quiet is stripped because the CLI answers +// it before any parser runs; --help and --json stay in the list, so a global +// usage request still reaches the compatibility-mode parser and every parser +// keeps owning --json. +func scanVerbFlags(verb string, args []string) (rest []string, help, jsonOutput, quiet bool) { + stopAtPayload := verb == "" || verb == "run" || verb == "sql" || verb == "ros" + rest = make([]string, 0, len(args)) + for i, arg := range args { + if arg == "--" || stopAtPayload && !strings.HasPrefix(arg, "-") { + rest = append(rest, args[i:]...) + break + } + switch arg { + case "--help": + help = true + rest = append(rest, arg) + case "--quiet", "--no-notices": + quiet = true + default: + if arg == "--json" { + jsonOutput = true + } + rest = append(rest, arg) + } + } + return rest, help, jsonOutput, quiet +} + +// knownVerb reports whether the first argument selects a subcommand parser +// rather than compatibility mode. +func knownVerb(verb string) string { + if containsString(helpVerbs, verb) { + return verb + } + return "" +} + +// compatOptionNames are the sshx-owned options accepted in compatibility mode +// before the remote command starts. It feeds the "did you mean" suggestion for +// an unrecognized option; TestCompatOptionNamesAreRecognized fails when an entry +// is not actually parsed, so the list cannot drift into fiction. +var compatOptionNames = []string{ + "-h", "--host", "-p", "--port", "-u", "--user", "-i", "--key", + "-pk", "--password-key", "--sudo-password-key", "--ssh-password-key", + "--no-key", "--password-only", "--key-auth", "--force", "-f", + "--bypass-reason", "--accept-unknown-host", "--insecure-hostkey", + "--strict-host-key", "--known-hosts", "--no-safety-check", "--dry-run", + "--audit-output", "--no-audit", "--json", "--pty", "--timeout", + "--expect-plan", "--host-timeout", "--global-timeout", "--bind", "--via", + "--sftp", "--upload", "--download", "--transfer", "--to", "--list", "--ls", + "--mkdir", "--rm", "--password-set", "--password-get", "--password-delete", + "--password-del", "--password-check", "--password-exists", "--password-list", + "--password-ls", "--host-add", "--host-import", "--ssh-config", + "--host-update", "--host-list", "--host-ls", "--host-test", "--host-test-all", + "--host-remove", "--host-rm", "--host-name", "--host-desc", "--host-type", +} + +// compatOptionHints explains the options callers most often guess at. They are +// not aliases: the upload/download surface is --upload= or +// --download= plus --to=, and the guessed --local/--remote +// pair is silently useless, so name the real surface instead of only the typo. +var compatOptionHints = map[string]string{ + "--local": "use --upload= --to= (or --download= --to=)", + "--remote": "use --download= --to= (or --upload= --to=)", +} + +// unknownCompatOption describes an option-shaped token that compatibility mode +// does not recognize. Before this rule the token was forwarded as part of the +// remote command, so a misspelled option ran something the caller never asked +// for and the resulting failure named the wrong cause. +func unknownCompatOption(token string) string { + name := token + if index := strings.Index(name, "="); index >= 0 { + name = name[:index] + } + if hint, ok := compatOptionHints[name]; ok { + return fmt.Sprintf("unknown option %q: %s", token, hint) + } + if suggestion := closestCompatOption(name); suggestion != "" { + return fmt.Sprintf("unknown option %q (did you mean %q?); sshx options come before the remote command, and a command that starts with \"-\" must follow --", token, suggestion) + } + return fmt.Sprintf("unknown option %q; sshx options come before the remote command, and a command that starts with \"-\" must follow --", token) +} + +// closestCompatOption returns the compatibility option nearest to an +// unrecognized one, or "" when nothing is close enough to suggest. +func closestCompatOption(name string) string { + return closestOptionName(name, compatOptionNames) +} + +// closestOptionName returns the known option nearest to an unrecognized token. +// Short names are specific, so they tolerate only one wrong character while +// longer names tolerate two, and one-character options are never suggested: +// each of them is one edit away from every other. +func closestOptionName(name string, candidates []string) string { + long := strings.HasPrefix(name, "--") + best, bestDistance := "", 3 + for _, candidate := range candidates { + if strings.HasPrefix(candidate, "--") != long { + continue + } + bare := strings.TrimLeft(candidate, "-") + if len(bare) < 2 { + continue + } + limit := 2 + if len(bare) <= 3 { + limit = 1 + } + if distance := levenshtein(name, candidate); distance <= limit && distance < bestDistance { + best, bestDistance = candidate, distance + } + } + return best +} + +// levenshtein returns the edit distance between two option names. +func levenshtein(a, b string) int { + previous := make([]int, len(b)+1) + current := make([]int, len(b)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(a); i++ { + current[0] = i + for j := 1; j <= len(b); j++ { + substitution := previous[j-1] + if a[i-1] != b[j-1] { + substitution++ + } + current[j] = min(previous[j]+1, current[j-1]+1, substitution) + } + previous, current = current, previous + } + return previous[len(b)] +} + // ParseArgs parses command-line arguments and returns a Config. func ParseArgs(args []string) *sshclient.Config { config := &sshclient.Config{ @@ -113,6 +275,25 @@ func ParseArgs(args []string) *sshclient.Config { RunTags: map[string]string{}, } + // sshx's own flags come before the payload, so scan them once here: --quiet + // must be known before the first notice is emitted, and --help must answer + // before any parser can reject it as an unknown option. Compatibility mode + // starts at the first argument (an sshx option or the remote command); a + // subcommand starts after its verb. + verb := "" + scanFrom := 1 + if len(args) > 1 { + verb = args[1] + if knownVerb(verb) != "" { + scanFrom = 2 + } + } + verbArgs := []string(nil) + var help, jsonOutput bool + if len(args) > scanFrom { + verbArgs, help, jsonOutput, config.Quiet = scanVerbFlags(knownVerb(verb), args[scanFrom:]) + } + if password := os.Getenv("SSH_PASSWORD"); password != "" { config.Password = password } @@ -134,10 +315,10 @@ func ParseArgs(args []string) *sshclient.Config { } // High-risk trust relaxations must be explicit CLI/request fields. Inherited // environment values and repository-local .env files must not authorize them. - warnDeprecatedTrustEnv("SSH_ACCEPT_UNKNOWN_HOST") - warnDeprecatedTrustEnv("SSH_INSECURE_HOST_KEY") - warnDeprecatedTrustEnv("SSH_NO_SAFETY_CHECK") - warnDeprecatedTrustEnv("SSH_FORCE") + warnDeprecatedTrustEnv(config, "SSH_ACCEPT_UNKNOWN_HOST") + warnDeprecatedTrustEnv(config, "SSH_INSECURE_HOST_KEY") + warnDeprecatedTrustEnv(config, "SSH_NO_SAFETY_CHECK") + warnDeprecatedTrustEnv(config, "SSH_FORCE") if timeoutStr := os.Getenv("SSH_TIMEOUT"); timeoutStr != "" { if d, err := parseTimeout(timeoutStr); err == nil { @@ -157,41 +338,53 @@ func ParseArgs(args []string) *sshclient.Config { } if len(args) > 1 { - switch args[1] { + if help { + if verb = knownVerb(verb); verb != "" { + config.HelpVerb = verb + config.JSONOutput = jsonOutput + } else { + config.ShowUsage = true + } + return config + } + switch verb { case "plugin": - parsePluginArgs(config, args[2:]) + parsePluginArgs(config, verbArgs) return config case "skill": - parseSkillArgs(config, args[2:]) + parseSkillArgs(config, verbArgs) return config case "mcp": - parseMCPArgs(config, args[2:]) + parseMCPArgs(config, verbArgs) return config case "inspect": - parseInspectArgs(config, args[2:]) + parseInspectArgs(config, verbArgs) return config case "run": - parseRunArgs(config, args[2:]) + parseRunArgs(config, verbArgs) return config case "sql": - parseSQLArgs(config, args[2:]) + parseSQLArgs(config, verbArgs) return config case "apply": - parseApplyArgs(config, args[2:]) + parseApplyArgs(config, verbArgs) return config case "text": - parseTextArgs(config, args[2:]) + parseTextArgs(config, verbArgs) return config case "audit": - parseAuditArgs(config, args[2:]) + parseAuditArgs(config, verbArgs) return config case "login": - parseLoginArgs(config, args[2:]) + parseLoginArgs(config, verbArgs) return config case "ros": - parseROSArgs(config, args[2:]) + parseROSArgs(config, verbArgs) return config } + // Compatibility mode: the scanned list already carries the first + // argument unless it was an sshx notice flag. + args = append([]string{args[0]}, verbArgs...) } commandParts := []string{} @@ -214,6 +407,8 @@ func ParseArgs(args []string) *sshclient.Config { config.KeyPath = strings.SplitN(arg, "=", 2)[1] config.UseKeyAuth = true case applySudoKeyFlag(config, arg): + case strings.HasPrefix(arg, "--ssh-password-key="): + config.SSHPasswordKey = strings.SplitN(arg, "=", 2)[1] case arg == "--no-key", arg == "--password-only": config.UseKeyAuth = false config.KeyPath = "" @@ -351,8 +546,16 @@ func ParseArgs(args []string) *sshclient.Config { case strings.HasPrefix(arg, "--host-type="): config.HostType = strings.SplitN(arg, "=", 2)[1] case arg == "--help": - PrintUsage() - os.Exit(0) + // Unreachable through ParseArgs (the pre-scan answers --help in option + // position), kept for callers that build a compatibility argument list. + config.ShowUsage = true + return config + case strings.HasPrefix(arg, "-"): + // Option position: everything here must be an sshx option. Forwarding + // an unrecognized token as a command made typos execute with defaults + // and produced an error that named the wrong cause. + config.ArgumentError = unknownCompatOption(arg) + return config default: if config.Mode == "ssh" { commandParts = append(commandParts, args[i:]...) @@ -416,6 +619,8 @@ func parsePluginArgs(config *sshclient.Config, args []string) { config.DryRun = true case arg == "--replace": config.PluginReplace = true + case arg == "--trust": + config.PluginTrust = true case strings.HasPrefix(arg, "--runner="): config.PluginRunner = strings.SplitN(arg, "=", 2)[1] case strings.HasPrefix(arg, "--platform="): @@ -430,8 +635,14 @@ func parsePluginArgs(config *sshclient.Config, args []string) { config.AuditOutput = strings.SplitN(arg, "=", 2)[1] case arg == "--no-audit": config.AuditEnabled = false - case !strings.HasPrefix(arg, "-") && config.PluginID == "": - config.PluginID = arg + case !strings.HasPrefix(arg, "-") && config.PluginID == "" && config.PluginSource == "": + // The install positional names the source directory; every other + // action takes a plugin id. + if config.PluginAction == "install" { + config.PluginSource = arg + } else { + config.PluginID = arg + } case !strings.HasPrefix(arg, "-"): config.ArgumentError = fmt.Sprintf("unexpected plugin argument %q", arg) default: @@ -442,9 +653,9 @@ func parsePluginArgs(config *sshclient.Config, args []string) { // warnDeprecatedTrustEnv emits a diagnostic when a high-risk env switch is set // without applying it. Explicit CLI flags remain the only authorization path. -func warnDeprecatedTrustEnv(name string) { +func warnDeprecatedTrustEnv(config *sshclient.Config, name string) { val := os.Getenv(name) - if val == "" { + if val == "" || config != nil && config.Quiet { return } if strings.EqualFold(val, "true") || val == "1" { @@ -620,6 +831,85 @@ func parseRunArgs(config *sshclient.Config, args []string) { } } +// sqlOptionNames are the options accepted by `sshx sql` before the statement +// starts. It feeds the "did you mean" suggestion for an unrecognized option; +// TestSQLOptionNamesAreRecognized fails when an entry is not actually parsed. +var sqlOptionNames = []string{ + "-h", "--host", "-p", "--port", "-u", "--user", "-i", "--key", "-pk", + "--password-key", "--sudo-password-key", "--ssh-password-key", "--no-key", + "--password-only", "--key-auth", "--accept-unknown-host", "--insecure-hostkey", + "--strict-host-key", "--known-hosts", "--engine", "--db", "--database", + "--db-file", "--db-user", "--db-host", "--db-port", "--db-password-key", + "--statement-file", "--row-threshold", "--allow-full-table", "--no-backup", + "--explain", "--backup-dir", "--docker", "--db-cred-from", "--cred-cache", + "--cred-refresh", "--sudo", "--force", "-f", "--dry-run", "--json", + "--timeout", "--bind", "--via", "--audit-output", "--no-audit", + "--bypass-reason", "--expect-plan", "--host-timeout", "--global-timeout", +} + +// sqlStatementToken reports whether an argument can only be statement text +// rather than an sshx option. SQL files, migrations, and dumps conventionally +// open with a comment header ("-- ..."), which is exactly a token that starts +// with "--" but cannot be an option because its name part contains whitespace. +func sqlStatementToken(arg string) bool { + if !strings.HasPrefix(arg, "--") { + return false + } + name, _, _ := strings.Cut(arg, "=") + return strings.ContainsAny(name, " \t\r\n") +} + +// unknownSQLOption describes an option-shaped SQL token. A statement that opens +// with a comment can look like an option, so the message names every way to pass +// a statement that begins with "-". +func unknownSQLOption(token string) string { + name, _, _ := strings.Cut(token, "=") + if suggestion := closestSQLOption(name); suggestion != "" { + return fmt.Sprintf("unknown sql option %q (did you mean %q?); a statement is accepted as a positional argument, after --, via --statement-file=PATH, or on stdin", token, suggestion) + } + return fmt.Sprintf("unknown sql option %q; a statement is accepted as a positional argument, after --, via --statement-file=PATH, or on stdin", token) +} + +// closestSQLOption returns the known sql option nearest to an unrecognized one. +func closestSQLOption(name string) string { + return closestOptionName(name, sqlOptionNames) +} + +// maxSQLStatementBytes bounds a statement read from a file or stdin so a stray +// stream cannot be buffered into memory. +const maxSQLStatementBytes = 1 << 20 + +// readSQLStatement loads one statement from a local file or, when stdin is +// piped rather than a terminal, from stdin. An empty pipe returns "" so the +// caller keeps its "statement is required" diagnostic. +func readSQLStatement(path string) (string, error) { + if path != "" { + data, err := os.ReadFile(path) // #nosec G304 -- caller-selected local statement file. + if err != nil { + return "", fmt.Errorf("read --statement-file: %w", err) + } + if len(data) > maxSQLStatementBytes { + return "", fmt.Errorf("--statement-file %s exceeds %d bytes", path, maxSQLStatementBytes) + } + return string(data), nil + } + info, err := os.Stdin.Stat() + if err != nil { + return "", nil + } + if info.Mode()&os.ModeCharDevice != 0 { + return "", nil + } + data, err := io.ReadAll(io.LimitReader(os.Stdin, maxSQLStatementBytes+1)) + if err != nil { + return "", fmt.Errorf("read SQL statement from stdin: %w", err) + } + if len(data) > maxSQLStatementBytes { + return "", fmt.Errorf("SQL statement on stdin exceeds %d bytes", maxSQLStatementBytes) + } + return string(data), nil +} + // parseSQLArgs parses the `sshx sql` guarded SQL execution subcommand. The // SQL statement is the positional argument (or everything after `--`). func parseSQLArgs(config *sshclient.Config, args []string) { @@ -667,6 +957,8 @@ func parseSQLArgs(config *sshclient.Config, args []string) { config.SQLDatabase = strings.SplitN(arg, "=", 2)[1] case strings.HasPrefix(arg, "--db-file="): config.SQLFile = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--statement-file="): + config.SQLStatementFile = strings.SplitN(arg, "=", 2)[1] case strings.HasPrefix(arg, "--db-user="): config.SQLUser = strings.SplitN(arg, "=", 2)[1] case strings.HasPrefix(arg, "--db-host="): @@ -727,14 +1019,37 @@ func parseSQLArgs(config *sshclient.Config, args []string) { config.AuditOutput = strings.SplitN(arg, "=", 2)[1] case arg == "--no-audit": config.AuditEnabled = false + case strings.HasPrefix(arg, "--") && sqlStatementToken(arg): + // A comment-leading statement is statement text, not an option. + sqlParts = append(sqlParts, args[i:]...) + i = len(args) case !strings.HasPrefix(arg, "-"): sqlParts = append(sqlParts, args[i:]...) i = len(args) default: - config.ArgumentError = fmt.Sprintf("unknown sql option %q", arg) + config.ArgumentError = unknownSQLOption(arg) } } config.SQLStatement = strings.TrimSpace(strings.Join(sqlParts, " ")) + switch { + case config.ArgumentError != "": + case config.SQLStatement != "" && config.SQLStatementFile != "": + config.ArgumentError = "--statement-file cannot be combined with a positional SQL statement" + case config.SQLStatementFile != "": + statement, readErr := readSQLStatement(config.SQLStatementFile) + if readErr != nil { + config.ArgumentError = readErr.Error() + } else { + config.SQLStatement = strings.TrimSpace(statement) + } + case config.SQLStatement == "": + statement, readErr := readSQLStatement("") + if readErr != nil { + config.ArgumentError = readErr.Error() + } else { + config.SQLStatement = strings.TrimSpace(statement) + } + } // Password auth implies TCP: peer/ident auth on the local socket ignores // PGPASSWORD, so default the database host to loopback in that case. @@ -930,8 +1245,6 @@ func parseTextArgs(config *sshclient.Config, args []string) { config.TextUseSudo = true case arg == "--no-redact": config.TextRedact = false - case arg == "--help": - config.TextHelp = true case arg == "--dry-run": config.DryRun = true case arg == "--json": diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 3b0623d..bb74fcc 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -100,7 +100,7 @@ func TestParseArgs_TextSubcommand(t *testing.T) { t.Fatalf("unexpected text flags: %#v", config) } help := ParseArgs([]string{"sshx", "text", "--help", "--json"}) - if !help.TextHelp || !help.JSONOutput { + if help.HelpVerb != "text" || !help.JSONOutput { t.Fatalf("text help not parsed: %#v", help) } denied := ParseArgs([]string{"sshx", "text", "-h=prod", "--command=grep foo"}) diff --git a/internal/app/diagnostics.go b/internal/app/diagnostics.go index 17286c1..189a08b 100644 --- a/internal/app/diagnostics.go +++ b/internal/app/diagnostics.go @@ -3,10 +3,23 @@ package app import ( "fmt" "io" + "os" + "github.com/talkincode/sshx/internal/sshclient" "github.com/talkincode/sshx/pkg/logger" ) +// noticeWriter is where human notices go for one invocation: stderr normally, and +// nothing at all when --quiet asked for a notice-free stream. A notice explains or +// narrates a result that has already been reported, so suppressing it never changes +// stdout, the exit code, or the JSON document (issue #86). +func noticeWriter(config *sshclient.Config) io.Writer { + if config != nil && config.Quiet { + return io.Discard + } + return os.Stderr +} + // writeDiagnosticNote writes a best-effort diagnostic note to w. // // Notes explain a result that has already been reported on stdout, so a failed diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 5d2eb4f..6c39b6a 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -175,6 +175,49 @@ func emitDryRunPlan(config *sshclient.Config) error { return nil } +// sftpPathCheck reports the missing path of an SFTP action, naming the option +// that supplies it. The compatibility surface splits a transfer across +// --upload= / --download= plus --to=, so a bare +// "path is required" left the caller guessing which option to add. +func sftpPathCheck(config *sshclient.Config) string { + remote, local := strings.TrimSpace(config.RemotePath), strings.TrimSpace(config.LocalPath) + switch config.SftpAction { + case "upload": + switch { + case local == "": + return "--upload= needs a local path" + case remote == "": + return "--upload needs a destination: add --to=" + } + case "download": + switch { + case remote == "": + return "--download= needs a remote path" + case local == "": + return "--download needs a destination: add --to=" + } + case "list", "mkdir", "remove": + if remote == "" { + return fmt.Sprintf("%s needs a remote path", sftpActionFlag(config.SftpAction)) + } + } + return "" +} + +// sftpActionFlag names the compatibility flag that carries an SFTP action path. +func sftpActionFlag(action string) string { + switch action { + case "list": + return "--list=" + case "mkdir": + return "--mkdir=" + case "remove": + return "--rm=" + default: + return "--" + action + "=" + } +} + func buildDryRunPlan(config *sshclient.Config) dryRunPlan { plan := dryRunPlan{ DryRun: true, @@ -346,7 +389,7 @@ func resolveDryRunSSHHost(config *sshclient.Config, plan *dryRunPlan) { if !config.BindSet && hostConfig.Bind != "" { config.Bind = hostConfig.Bind } - if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" && config.SudoKey == sshclient.DefaultSudoKey { + if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" && !sudoKeyChosen(config) { config.SudoKey = sudoKey } if config.SSHPasswordKey == "" { @@ -450,7 +493,7 @@ func resolveDryRunHostTest(config *sshclient.Config, plan *dryRunPlan) { config.SSHPasswordKey = sshKey plan.hostTestReadsSecret = true } - if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" { + if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" && !sudoKeyChosen(config) { config.SudoKey = sudoKey } if !config.BindSet { @@ -548,19 +591,12 @@ func fillDryRunValidation(config *sshclient.Config, plan *dryRunPlan) { return } if config.Mode == "sftp" { - // Mirror the MCP adapter's pre-flight checks: an empty path cannot - // produce a plan, and rejecting it here keeps the diagnostic a config - // error instead of a misleading connection failure. - if strings.TrimSpace(config.RemotePath) == "" { - plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: "config", Message: "remote path is required"} + // Match the MCP adapter's pre-flight check for the same condition (an + // empty path cannot produce a plan); the CLI wording names the flag that + // supplies the missing value, while MCP names its JSON fields. + if message := sftpPathCheck(config); message != "" { + plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: "config", Message: message} plan.Valid = false - return - } - if config.SftpAction == "upload" || config.SftpAction == "download" { - if strings.TrimSpace(config.LocalPath) == "" { - plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: "config", Message: "local path is required"} - plan.Valid = false - } } return } diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 5cdc58b..6942cee 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "os" "strings" "time" @@ -115,8 +114,9 @@ func emitLifecycleJSON(config *sshclient.Config, value any) error { return err } // Write the human-readable mirror first so the JSON document stays the last - // line a caller that merges stderr into stdout would have to parse. - reportPolicyRejection(os.Stderr, document) + // line a caller that merges stderr into stdout would have to parse. --quiet + // suppresses the mirror without touching the document. + reportPolicyRejection(noticeWriter(config), document) if err := encodeJSON(document); err != nil { return fmt.Errorf("%w: deliver execution result: %w", execution.ErrLocalIO, err) } diff --git a/internal/app/lifecycle_policy_test.go b/internal/app/lifecycle_policy_test.go index e2d855b..e1995d3 100644 --- a/internal/app/lifecycle_policy_test.go +++ b/internal/app/lifecycle_policy_test.go @@ -156,6 +156,33 @@ func TestBlockedJSONKeepsStdoutPureAndMirrorsStderr(t *testing.T) { assert.NotContains(t, string(stdout), "blocked by safety policy", "the mirror must not leak into stdout") } +// TestBlockedJSONMirrorIsSilencedByQuiet: --quiet keeps the same single JSON +// document on stdout and drops the human mirror from stderr, so a caller that +// merges the streams still reads exactly one parseable document (issue #86). +func TestBlockedJSONMirrorIsSilencedByQuiet(t *testing.T) { + config := &sshclient.Config{ + Mode: "ssh", + Host: "db1.example.net", + Port: "22", + User: "operator", + Command: `docker exec teamsacs_pgdb18 psql -U teamsacs -d teamsacs -At -c 'select 1'`, + JSONOutput: true, + Quiet: true, + } + blocked := &sshclient.CommandBlockedError{ + Command: config.Command, + Reason: `Direct PostgreSQL client execution ("psql") bypasses the guarded SQL pipeline.`, + } + + var runErr error + stdout, stderr := captureStreams(t, func() { + runErr = reportSSHFailure(config, nil, sshclient.AuthMethodUnknown, "blocked", blocked) + }) + require.ErrorIs(t, runErr, ErrReported) + assert.True(t, json.Valid(bytes.TrimSpace(stdout)), "stdout must stay a single JSON document: %q", stdout) + assert.Empty(t, stderr, "--quiet must suppress the policy mirror on stderr") +} + // TestNonBlockedJSONFailureHasNoMirror: a plain remote failure keeps today's // behavior and writes nothing extra to stderr. func TestNonBlockedJSONFailureHasNoMirror(t *testing.T) { diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 3ab884f..ce42a48 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -210,9 +210,10 @@ func execMCPCommand(ctx context.Context, cmd *exec.Cmd, stdin string, globalTime return nil, nil, err } defer process.close() - if stdin != "" { - cmd.Stdin = strings.NewReader(stdin) - } + // Always replace the child's stdin: a one-shot tool call must never be able + // to read the MCP client's protocol stream, and an empty payload must reach + // EOF instead of blocking. Tools that take no stdin payload get "". + cmd.Stdin = strings.NewReader(stdin) // Command's copying goroutines drain both pipes independently. Output and // progress are bounded, and neither writer waits for an MCP client. progress, finishProgress := mcpProgressDispatcher(onEvent) diff --git a/internal/app/plan.go b/internal/app/plan.go index 60ccbe6..bed1ae5 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -422,7 +422,7 @@ func prepareRunPlan(config *sshclient.Config, req *execution.Request, snap *exec } } copyConfig.SSHPasswordKey = firstNonEmpty(req.Policy.SSHPasswordKey, t.SSHPasswordKey) - copyConfig.SudoKey = firstNonEmpty(req.Policy.SudoPasswordKey, t.SudoPasswordKey) + copyConfig.SudoKey = execution.SudoKeyForTarget(req.Policy.SudoPasswordKey, t.SudoPasswordKey) plan.Targets = append(plan.Targets, publicPlanTarget(©Config, t.Alias, "target", plan)) t.KnownHostsData, t.ExpectedKeyFingerprint = copyConfig.KnownHostsData, copyConfig.ExpectedKeyFingerprint t.Bind = copyConfig.Bind diff --git a/internal/app/plan_admission_test.go b/internal/app/plan_admission_test.go index 350726d..fd9ef72 100644 --- a/internal/app/plan_admission_test.go +++ b/internal/app/plan_admission_test.go @@ -198,13 +198,13 @@ func TestPlanAdmissionRejectsEmptySFTPPaths(t *testing.T) { args []string message string }{ - {"upload without local path", []string{"--upload=", "--to=/remote/file"}, "local path is required"}, - {"upload without remote path", []string{"--upload=/etc/hosts"}, "remote path is required"}, - {"download without local path", []string{"--download=/remote/file"}, "local path is required"}, - {"download without remote path", []string{"--download=", "--to=/local/file"}, "remote path is required"}, - {"list without remote path", []string{"--list="}, "remote path is required"}, - {"mkdir without remote path", []string{"--mkdir="}, "remote path is required"}, - {"remove without remote path", []string{"--rm="}, "remote path is required"}, + {"upload without local path", []string{"--upload=", "--to=/remote/file"}, "--upload= needs a local path"}, + {"upload without destination", []string{"--upload=/etc/hosts"}, "--upload needs a destination: add --to="}, + {"download without local path", []string{"--download=/remote/file"}, "--download needs a destination: add --to="}, + {"download without remote path", []string{"--download=", "--to=/local/file"}, "--download= needs a remote path"}, + {"list without remote path", []string{"--list="}, "--list= needs a remote path"}, + {"mkdir without remote path", []string{"--mkdir="}, "--mkdir= needs a remote path"}, + {"remove without remote path", []string{"--rm="}, "--rm= needs a remote path"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/app/plugin.go b/internal/app/plugin.go index fe62869..f4751e7 100644 --- a/internal/app/plugin.go +++ b/internal/app/plugin.go @@ -16,7 +16,7 @@ func HandlePluginManagement(config *sshclient.Config) error { } action := config.PluginAction if action == "" { - return reportPluginError(config, "config", fmt.Errorf("plugin action is required: create, list, show, validate, test, trust, or remove")) + return reportPluginError(config, "config", fmt.Errorf("plugin action is required: create, install, list, show, validate, test, trust, or remove")) } var result pluginpkg.ActionResult @@ -55,10 +55,31 @@ func HandlePluginManagement(config *sshclient.Config) error { }, } } + case "install": + if config.PluginSource == "" { + err = fmt.Errorf("plugin source directory is required (sshx plugin install )") + break + } + var installed *pluginpkg.InstallResult + installed, err = pluginpkg.Install(pluginpkg.InstallOptions{ + Source: config.PluginSource, + Replace: config.PluginReplace, + Trust: config.PluginTrust, + }) + if err == nil { + resolved := installed.Resolved + result = pluginpkg.ActionResult{ + Success: true, Action: action, PluginID: resolved.Manifest.ID, + Path: resolved.Path, PluginRoot: pluginRootOrEmpty(), Source: config.PluginSource, + BackupPath: installed.BackupPath, Digest: resolved.Digest, + Trusted: resolved.Trusted, Builtin: resolved.Builtin, Valid: true, Files: installed.Files, + NextActions: pluginNextActions(resolved.Manifest.ID, resolved.Trusted), + } + } case "list": var plugins []pluginpkg.Summary plugins, err = pluginpkg.List() - result = pluginpkg.ActionResult{Success: err == nil, Action: action, Plugins: plugins} + result = pluginpkg.ActionResult{Success: err == nil, Action: action, Plugins: plugins, PluginRoot: pluginRootOrEmpty()} case "show", "validate": if config.PluginID == "" { err = fmt.Errorf("plugin id is required") @@ -68,7 +89,7 @@ func HandlePluginManagement(config *sshclient.Config) error { resolved, err = pluginpkg.Resolve(config.PluginID) if err == nil { summary := pluginpkg.SummaryFromResolved(resolved) - result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: resolved.Trusted, Valid: true, Plugin: &summary, Manifest: &resolved.Manifest} + result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: resolved.Trusted, Builtin: resolved.Builtin, Valid: true, Plugin: &summary, Manifest: &resolved.Manifest} } case "test": if config.PluginID == "" { @@ -80,7 +101,7 @@ func HandlePluginManagement(config *sshclient.Config) error { var testResult pluginpkg.Result resolved, testResult, fixture, err = pluginpkg.Test(config.PluginID, config.PluginFixture) if err == nil { - result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: resolved.Trusted, Valid: true, Fixture: fixture, TestResult: &testResult} + result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: resolved.Trusted, Builtin: resolved.Builtin, Valid: true, Fixture: fixture, TestResult: &testResult} } case "trust": if config.PluginID == "" { @@ -90,7 +111,7 @@ func HandlePluginManagement(config *sshclient.Config) error { var resolved *pluginpkg.Resolved resolved, err = pluginpkg.Trust(config.PluginID) if err == nil { - result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: true, Valid: true} + result = pluginpkg.ActionResult{Success: true, Action: action, PluginID: config.PluginID, Path: resolved.Path, Digest: resolved.Digest, Trusted: true, Builtin: resolved.Builtin, Valid: true} } case "remove": if config.PluginID == "" { @@ -175,9 +196,7 @@ func emitPluginResult(config *sshclient.Config, result pluginpkg.ActionResult) e return nil } if result.Action == "list" { - for _, summary := range result.Plugins { - fmt.Printf("%s\t%s\ttrusted=%t\tbuiltin=%t\tvalid=%t\n", summary.ID, summary.Version, summary.Trusted, summary.Builtin, summary.Valid) - } + printPluginList(result) return nil } fmt.Printf("plugin %s: %s", result.Action, result.PluginID) @@ -188,5 +207,59 @@ func emitPluginResult(config *sshclient.Config, result pluginpkg.ActionResult) e fmt.Printf("; backup=%s", result.BackupPath) } fmt.Println() + if result.Digest != "" { + fmt.Printf(" builtin=%t trusted=%t valid=%t digest=%s\n", result.Builtin, result.Trusted, result.Valid, result.Digest) + } + for _, next := range result.NextActions { + fmt.Printf(" next: %s\n", next) + } return nil } + +// printPluginList groups the inventory by provenance and always names the local +// plugin root, so "no local plugins installed" is visible as such instead of +// having to be inferred from the absence of rows, and a local entry shows the +// state and digest a caller needs before running it. +func printPluginList(result pluginpkg.ActionResult) { + builtins, local := []pluginpkg.Summary{}, []pluginpkg.Summary{} + for _, summary := range result.Plugins { + if summary.Builtin { + builtins = append(builtins, summary) + } else { + local = append(local, summary) + } + } + fmt.Printf("built-in capabilities (%d):\n", len(builtins)) + for _, summary := range builtins { + fmt.Printf(" %s\t%s\ttrusted=true\tvalid=%t\n", summary.ID, summary.Version, summary.Valid) + } + fmt.Printf("local plugins (%d) in %s:\n", len(local), result.PluginRoot) + if len(local) == 0 { + fmt.Printf(" (none; use \"sshx plugin install \" or \"sshx plugin create \")\n") + return + } + for _, summary := range local { + if !summary.Valid { + fmt.Printf(" %s\tINVALID\t%s\t%s\n", summary.ID, summary.ErrorKind, summary.Error) + continue + } + fmt.Printf(" %s\t%s\ttrusted=%t\tvalid=true\tdigest=%s\n", summary.ID, summary.Version, summary.Trusted, summary.Digest) + } +} + +// pluginRootOrEmpty names the local plugin root for discovery output. +func pluginRootOrEmpty() string { + root, err := pluginpkg.Root() + if err != nil { + return "" + } + return root +} + +// pluginNextActions points at the remaining step after a plugin lands. +func pluginNextActions(id string, trusted bool) []string { + if trusted { + return []string{"sshx plugin test " + id + " --json", "sshx inspect -h= " + id + " --json"} + } + return []string{"sshx plugin validate " + id + " --json", "sshx plugin trust " + id + " --json", "sshx inspect -h= " + id + " --json"} +} diff --git a/internal/app/run.go b/internal/app/run.go index 1683e5f..e8cbabd 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -121,14 +121,14 @@ func HandleRun(config *sshclient.Config, audit *auditRecorder) error { if execErr != nil { if outcome.RunID != "" { recordRunAudit(audit, config, req, snap, outcome) - reportRunSudoPromptFailures(outcome, req.Action.Command) + reportRunSudoPromptFailures(config, outcome, req.Action.Command) return execErr } return reportRunRequestFailure(config, audit, execErr) } recordRunAudit(audit, config, req, snap, outcome) - reportRunSudoPromptFailures(outcome, req.Action.Command) + reportRunSudoPromptFailures(config, outcome, req.Action.Command) // Single-target --json emits one versioned result document. if req.JSONOutput && !req.JSONLOutput && outcome.Single != nil { @@ -153,20 +153,21 @@ func HandleRun(config *sshclient.Config, audit *auditRecorder) error { // mid-command sudo stops with "a password is required". It writes straight to // stderr because the caller explaining a failure may be running with // diagnostics quieted, and stdout must keep exactly one result document. -func reportRunSudoPromptFailures(outcome execution.RunOutcome, command string) { +func reportRunSudoPromptFailures(config *sshclient.Config, outcome execution.RunOutcome, command string) { hint, needed := sshclient.NonLeadingSudoHint(command) if !needed { return } + out := noticeWriter(config) for _, res := range outcome.Results { if !sshclient.SudoPasswordPromptFailure(res.Stderr + res.Stdout) { continue } if label := res.Target.Alias; label != "" { - fmt.Fprintf(os.Stderr, "sshx: [%s] %s\n", label, hint) + writeDiagnosticNote(out, "sshx: [%s] %s\n", label, hint) continue } - fmt.Fprintf(os.Stderr, "sshx: %s\n", hint) + writeDiagnosticNote(out, "sshx: %s\n", hint) } } @@ -385,10 +386,13 @@ func buildRunRequest(config *sshclient.Config) (*execution.Request, *execution.P UseKeyAuth: config.UseKeyAuth, KeyPath: config.KeyPath, SSHPasswordKey: config.SSHPasswordKey, - SudoPasswordKey: config.SudoKey, - SSHPassword: config.Password, - Bind: config.Bind, - BindSet: config.BindSet, + // Run resolves each target's sudo key from its host record, so the policy + // carries only an explicit caller choice: the built-in default must not + // shadow a host's sudo_password_key (issue #78). + SudoPasswordKey: sudoKeyChoice(config), + SSHPassword: config.Password, + Bind: config.Bind, + BindSet: config.BindSet, }, JSONOutput: config.JSONOutput, JSONLOutput: config.JSONLOutput, @@ -492,6 +496,12 @@ func recordRunAudit(audit *auditRecorder, config *sshclient.Config, req *executi audit.event.Concurrency = req.Limits.Concurrency audit.event.FailureMode = req.Policy.FailureMode audit.event.TargetCount = snap.Count + // Run resolves the sudo key per target, so the summary records only a caller + // choice: the built-in default would misreport a fleet whose hosts each carry + // their own sudo_password_key (issue #78). Per-target events record the + // reference each host actually used. + audit.event.SudoKey = sudoKeyChoice(config) + audit.sudoKeyResolved = true audit.completed = true audit.event.Metadata = outcome.Metadata if outcome.Counts.Succeeded == outcome.Counts.Selected && outcome.Counts.Failed == 0 { @@ -515,10 +525,17 @@ func writeTargetAudit(config *sshclient.Config, runID string, req *execution.Req if config == nil || !config.AuditEnabled || config.DryRun { return nil } - rec := newAuditRecorder(config) + // The audit event must name the credential this target used: run resolves the + // sudo and SSH password keys from the host record, while the caller-level + // config still carries the caller's choice or the built-in default. + targetConfig := *config + targetConfig.SudoKey = execution.SudoKeyForTarget(req.Policy.SudoPasswordKey, tr.Target.SudoPasswordKey) + targetConfig.SSHPasswordKey = firstNonEmptyStr(req.Policy.SSHPasswordKey, tr.Target.SSHPasswordKey) + rec := newAuditRecorder(&targetConfig) if rec == nil { return nil } + rec.sudoKeyResolved = true rec.event.Mode = "run" rec.event.Action = req.Action.Kind rec.event.RunID = runID @@ -547,7 +564,7 @@ func writeTargetAudit(config *sshclient.Config, runID string, req *execution.Req } else { rec.event.Outcome = auditStatus{Status: "failed"} } - return rec.finish(config, nil) + return rec.finish(&targetConfig, nil) } func max(a, b int) int { diff --git a/internal/app/sql.go b/internal/app/sql.go index 8bad27e..ccaf898 100644 --- a/internal/app/sql.go +++ b/internal/app/sql.go @@ -262,7 +262,7 @@ func validateSQLConfig(config *sshclient.Config) error { return fmt.Errorf("host is required (use -h=)") } if config.SQLStatement == "" { - return fmt.Errorf("SQL statement is required (positional argument or after --)") + return fmt.Errorf("SQL statement is required (positional argument, after --, --statement-file=PATH, or on stdin)") } if config.SQLEngine == sqlsafe.EngineSQLite { return validateSQLiteConfig(config) diff --git a/internal/app/text.go b/internal/app/text.go index 663b134..0d41774 100644 --- a/internal/app/text.go +++ b/internal/app/text.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "os" "strings" "time" @@ -161,7 +160,7 @@ func (r *textRun) collect(req textsafe.Request) (textsafe.Result, error) { // A streamed SFTP window is the only source that can idle for minutes, so it // is the one that narrates progress. stdout stays untouched because the // reporter writes to stderr. - r.reporter = newTextScanReporter(os.Stderr, req.Pattern != "") + r.reporter = newTextScanReporter(noticeWriter(r.config), req.Pattern != "") r.scanStart = time.Now() switch req.Kind { case textsafe.SourceFile: diff --git a/internal/app/text_progress_test.go b/internal/app/text_progress_test.go index 22a6a01..d8b5f05 100644 --- a/internal/app/text_progress_test.go +++ b/internal/app/text_progress_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/sshclient" "github.com/talkincode/sshx/internal/textsafe" ) @@ -224,7 +225,7 @@ func TestReportRunSudoPromptFailuresPerTarget(t *testing.T) { } _, stderr := captureStreams(t, func() { - reportRunSudoPromptFailures(outcome, command) + reportRunSudoPromptFailures(&sshclient.Config{}, outcome, command) }) // Only the target that actually hit the prompt is named. @@ -266,9 +267,17 @@ func TestReportRunSudoPromptFailuresStaysQuiet(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, stderr := captureStreams(t, func() { - reportRunSudoPromptFailures(tc.outcome, tc.command) + reportRunSudoPromptFailures(&sshclient.Config{}, tc.outcome, tc.command) }) assert.Empty(t, string(stderr)) }) } + + // --quiet suppresses the hint even when it would otherwise be printed. + t.Run("quiet suppresses the hint", func(t *testing.T) { + _, stderr := captureStreams(t, func() { + reportRunSudoPromptFailures(&sshclient.Config{Quiet: true}, refusal, `cd /srv && sudo id`) + }) + assert.Empty(t, string(stderr)) + }) } diff --git a/internal/app/usage.go b/internal/app/usage.go index be0fff1..e61ec97 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -1,753 +1,195 @@ package app -import "fmt" +import ( + "fmt" + "strings" + + "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/sshclient" +) // Version is the sshx build version, set by the main package at startup // (injected via -ldflags). Defaults to "dev" for go test / go run builds. var Version = "dev" -// PrintUsage prints the usage information for the sshx command. -func PrintUsage() { - fmt.Printf("\nSSHX — Agent-native remote host execution over SSH\nVersion: %s\n", Version) - fmt.Println(` -Usage: - sshx -h= [options] # SSH mode (compatibility) - sshx run [selectors] [options] -- # Canonical execution contract - sshx run --script-file=PATH ... # Byte-preserving script payload - sshx -h= [options] --upload= # SFTP upload - sshx -h= [options] --download= # SFTP download - sshx --transfer=: --to=: # Server-to-server transfer - sshx --password-set=[:] # Store a password (keyring or local vault) - sshx --password-get= # Get password from OS keyring (denied for local vault) - sshx --password-delete= # Delete a stored password - sshx --password-list # List stored or common password keys - sshx --host-add # Add host configuration - sshx --host-update # Update host configuration - sshx --host-list # List configured hosts - sshx --host-test= # Test host connection - sshx --host-test-all # Test all host connections - sshx --host-remove= # Remove host configuration - sshx skill install [options] # Install/update the bundled Agent skill - sshx plugin create [options] # Scaffold a local inspection plugin - sshx plugin list [--json] # List built-in and local capabilities - sshx inspect -h= [options] # Run one structured host inspection - sshx sql -h= --db= [options] "SQL" # Guarded SQL via remote psql/sqlite3 - sshx ros -h= [options] # MikroTik RouterOS over SSH - sshx apply -h= --path= --from= # Guarded remote file apply - sshx text -h= --path= [options] # Bounded remote text/log dissection - sshx login [--sudo] # Human interactive login (TTY required) - sshx mcp # Serve the execution contract over stdio (MCP) - sshx audit query [filters] [--json] # Read-only audit trail query - sshx audit export --to= [filters] # Export matching audit events as JSONL - -SSH Options: - -h, --host=HOST Remote host address (required in compatibility mode) - -p, --port=PORT SSH port (default: 22) - -u, --user=USER SSH username (default: master) - -i, --key=PATH SSH private key path (default: ~/.ssh/id_rsa) - -pk, --password-key=KEY Sudo password keyring key name (default: master) - Used only when the remote command starts with sudo - --ssh-password-key=KEY SSH login password keyring key (never used for sudo) - --bind=ADDR Local source IP or interface (e.g. 192.0.2.10 or en0) - --via=NAME Named sshx jump host (session-bound; no local tunnel) - --dry-run Print the local execution plan without side effects - --expect-plan=HASH Require the reviewed sha256:<64 lowercase hex> plan - --audit-output=DIR Write audit JSONL files to DIR (default: ~/.sshx/audit) - --no-audit Disable local audit event writing for this invocation - --timeout=DURATION Command execution timeout (e.g. 30s, 2m, or 30 = seconds) - --host-timeout=DURATION Optional total admitted-target budget (setup + verify) - --global-timeout=DURATION Optional operation budget, including queued targets - --json Emit a single structured JSON result on stdout - --pty Request a PTY (merges stderr into stdout; off by default) - --version Show version information (alias: -v) - --help Show this help message - -Run Contract (preferred for Agents): - 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 - cat ./check.sh | sshx run --target=prod-web --script-stdin --json - - Selectors (configured hosts only; multi-host never treats names as DNS): - --target=NAME strict alias (repeatable via --targets=a,b) - --group=NAME union with other names/groups (repeatable) - --tag=key=value AND filter (repeatable) - --all-hosts all configured hosts before tag filters - --address=HOST explicit single literal address (not for fan-out) - - Script payloads: - --script-file=PATH byte-preserving script from a local file - --script-stdin byte-preserving script from stdin - --shell=NAME interpreter override: sh, bash, zsh, dash, ksh, ash - (default: the script's #! line, else sh) - - Limits / policy: - --concurrency=N default 4, hard max 32 - --failure-mode=continue|fail_fast default continue - --fail-fast Alias of --failure-mode=fail_fast - --max-failures=N Stop new admission at this failure threshold - --intent=read|change|unknown - --force / --no-safety-check require --bypass-reason=TEXT - --jsonl stream run_started/target_*/run_finished events - - Failure thresholds stop admission only; already admitted targets finish and - can add failures. Conflicting failure policies are configuration errors. - New time budgets are opt-in; --timeout keeps its existing meaning/defaults. - Cancellation closes local transport work, not guaranteed remote termination - or rollback. Treat unacknowledged changes as uncertain before retrying. - - Multi-target exit codes: - 0 all selected targets succeeded - 1 run accepted but at least one target failed/skipped/uncertain - 255 request-level failure (bad selectors, zero matches, invalid input) - -Agent / Scripting Mode: - By default command output streams live with stdout and stderr kept on - separate channels (no PTY), and the remote command's exit status is - propagated as sshx's own exit code. - - Compatibility --json emits one JSON object on stdout: - {host, port, user, command, exit_code, success, stdout, stderr, - stdout_truncated, stderr_truncated, duration_ms, auth_method, - error_kind, error} - sshx run --json adds versioned fields (schema_version, run_id, status, - phase, completion, structured error). - - Shared Execution Evidence: - plan_hash, risk, effects, execution_id, parent_execution_id, - execution_fingerprint, change_state, executed (nullable), verified, - verification, preconditions, postconditions. - change_state is changed|unchanged|unknown; null executed is not false. - Success, execution acknowledgement, change, and verification are distinct. - Unknown commands/scripts default to mutation risk with unknown effects; - --intent=read is not proof of read-only behavior. Risk is - read|mutation|privileged|destructive, not an authorization grant. - Raw stdout/stderr and secret values are not execution-fingerprinted. - - Exit codes (single-host compatibility mode): - 0 command succeeded - 1..254 remote command's exit status (propagated verbatim) - 255 sshx-level failure (connect/auth/host-key/timeout/blocked/...) - In --json mode an sshx-level failure has exit_code -1 and a non-empty - error_kind (timeout, auth, host_key, connect, blocked, exit_missing, - config, error), so it is always distinguishable from a remote exit 255. - - A policy block also writes its reason to stderr - (exit_code=-1, error_kind=blocked, phase=admission, executed=false), so a - caller that only prints the streams sees why nothing ran. Use the guarded - alternative instead: sshx sql -h= --db= [--docker=] - "". stdout still carries exactly one JSON document. - - Trust note: high-risk bypasses (force, no-safety-check, accept-unknown-host, - insecure-hostkey) require explicit CLI flags. Inherited env values and - working-directory .env files are ignored for those decisions. - -Sudo Auto-fill: - sshx auto-fills a sudo password only when the remote command starts with - sudo, for example: - sshx -h=host "sudo systemctl status nginx" - - Non-leading sudo is not auto-filled and does not trigger keyring lookup: - sshx -h=host "sh -c 'sudo whoami'" - sshx -h=host "echo sudo" - - sshx warns on stderr when it sees a non-leading sudo, because the remote then - stops with "sudo: a password is required". Wrap the privileged part instead: - sshx -h=host "sudo sh -c 'cd /data/app && docker compose up -d'" - - This keeps keyring lookup, stdin password injection, and future audit fields - on one clear rule. Put sudo at the beginning of the remote command when you - want sshx to auto-fill it. - -Dry-run Plan Preview: - Add --dry-run to see how sshx would interpret an invocation before any - connection, command execution, keyring secret lookup, known_hosts mutation, or - settings write. Use --json with --dry-run for an agent-readable plan. - - Examples: - sshx -h=prod-web --dry-run "sudo systemctl restart nginx" - sshx -h=prod-web --dry-run --json --upload=local.txt --to=/tmp/local.txt - - Dry-run is a local plan preview only. It does not prove the remote command - would succeed. - - Bound Plan Admission: - Remote command/run/apply/sql/SFTP/transfer/inspect previews add a nested - sshx.plan.v1 plan plus plan_hash and risk. Run keeps sshx.request.v1 outside. - Repeat reviewed inputs with --expect-plan=sha256:<64 lowercase hex>. - Local mismatch fails before secret lookup or network work; --force cannot - bypass it. Errors: config (format), plan_mismatch, plan_unresolved. - Check plan.bindable and plan.unresolved: DNS-only addresses, unavailable - public-key sidecars, missing/relaxed trust, and remotely discovered SQL - identity cannot bind offline. The entire sorted known_hosts record snapshot - is hashed, so unrelated trust changes can conservatively invalidate a plan. - Dry-run never connects, reads secrets, or writes state. A plan hash is not - a lock on remote files, rows, permissions, or recursive directory membership. - -Audit Trail: - sshx writes one structured JSONL audit event per non-dry-run invocation to - ~/.sshx/audit/sshx-YYYY-MM-DD.jsonl by default. Use --audit-output= to - save audit events next to a project or incident record. - - Audit events record metadata and outcomes such as mode/action, host - resolution, sudo/keyring decisions, safety status, auth method, exit code, and - error kind. They do not record plaintext passwords, private key contents, or - stdout/stderr. Command text is best-effort redacted for password/token-style - arguments. - - Read-only consumption: - sshx audit query --since=2026-09-01 --target=prod-web --json - sshx audit query --run-id= --error-kind=blocked --bypass-only - sshx audit query --execution-id= --json - sshx audit export --to=./incident.jsonl --since=2026-09-01 - Empty query results exit 0. JSON mode emits {schema_version, success, count, events}. - Corrupt/partial records have visible diagnostics; valid records are retained. - Audit writes are best-effort, with persistence status separate from execution. - Do not repeat a successful mutation just because audit writing failed. - -Safety Options: - -f, --force Force execution, bypass safety checks (use with caution!) - --no-safety-check Disable safety checks completely (not recommended) - --bypass-reason=TEXT Required with --force / --no-safety-check in command - mode and sshx run (recorded in dry-run, result, audit) - - Safety checks protect against: - - Destructive operations (rm -rf /, mkfs, dd) - - System shutdown/reboot commands - - Critical file modifications (/etc/passwd, /etc/shadow) - - Dangerous pipe operations (curl | sh) - - Fork bombs and other malicious patterns - - Direct database client execution (psql/pgcli/sqlite3/mysql/mariadb, - incl. docker exec, sudo -u, sh -c, kubectl exec wrappers) — use 'sshx sql' instead - -SFTP Options: - --upload= Upload file (use with --to=) - --download= Download file (use with --to=) - --to= Target path for upload/download - --list= List directory contents (alias: --ls) - --mkdir= Create remote directory - --rm= Remove remote file or directory - - Add --json for structured operation/effect evidence. Size-only verification - does not prove content equality. Partial recursive progress is not rolled - back as one atomic directory operation. - -Server-to-Server Transfer: - --transfer=: --to=: - - Streams files directly from one server to another through the local - machine (nothing is written to local disk). Supports single files and - recursive directory transfers, and preserves file permission bits. - Both hosts can be configured host names (from ~/.sshx/settings.json) - or IP addresses, each using its own SSH key/user/port from settings. - -Password Management (Cross-Platform): - --password-set=[:] Store a password in the secret backend - If password omitted, will prompt - --password-get= OS keyring: emit the value only when piped. - Local vault: always refused (write-only). - --password-check= Check if password exists (alias: --password-exists). - Missing keys exit non-zero. --json emits - sshx.secrets.v1 with success/exists. - --password-delete= Delete password (alias: --password-del) - --password-list List stored keys (vault) or common keys (keyring). - --json emits keys[] (list_complete=false on keyring probes) - - Default backend: OS keyring (macOS Keychain / Linux Secret Service / - Windows Credential Manager). Headless servers can opt into an encrypted - local vault with SSHX_SECRET_BACKEND=local-vault. There is no silent - fallback. The vault never displays secret values; sshx injects them over - stdin during execution. Unlock with SSHX_VAULT_PASSPHRASE or - SSHX_VAULT_KEY_FILE (0600). The vault file is $SSHX_HOME/vault. - -Host Management: - --host-add Add new host (interactive or with options). - Omitting -pk does not persist sudo_password_key=master. - --json emits sshx.hosts.v1 for add/update/remove/test. - --host-import Selectively import hosts from ~/.ssh/config (interactive) - --host-import= Import only the named ssh_config hosts (non-interactive) - --ssh-config= ssh_config file to import from (default: ~/.ssh/config) - --host-update Update existing host configuration - --host-list List all configured hosts (alias: --host-ls) - --host-test= Test connection to configured host - --host-test-all Test connections for all configured hosts - --host-remove= Remove host from configuration (alias: --host-rm) - - Host Add/Update Options: - --host-name= Host name (unique identifier, required for update) - --host-desc= Host description - -h=
Host address (IP or hostname) - -p= SSH port - -u= SSH username - -i=, --key= SSH private key path for this host (optional) - -pk= Password key name - --host-type= System type (linux/windows/macos) - --bind= Persist a source address for this host - --via= Named jump host for this target - - Configuration file: ~/.sshx/settings.json - -Inspection Capabilities: - sshx inspect -h= [options] - - Built in: - system.identity, system.resources, system.baseline - network.interfaces, network.routes, network.dns - network.listeners, network.firewall - - --cache=off|remote-prefer Reuse/write a remote observation (default: off) - --refresh Ignore a reusable observation and run the collector - --max-age=DURATION Require observations no older than this duration - --allow-stale Explicitly allow an expired observation - --sudo Use sudo for an optional-privilege plugin - - Collectors execute once through SSH stdin and are never installed on the - target. With remote-prefer caching, only redacted JSON is stored below the - remote user's ~/.sshx/observations/v1 directory. - -Guarded SQL Execution: - sshx sql -h= --db= [options] "" - sshx sql -h= --engine=sqlite --db-file=/abs/path.db [options] "SQL" - sshx sql -h= --db= [options] -- - - Statements run through the database client already present on the remote - host (psql, sqlite3, or mysql/mariadb). sshx embeds no database driver and opens no tunnel. - Exactly one statement per invocation. Unknown or dangerous statement heads - (DROP DATABASE/SCHEMA, ALTER SYSTEM, COPY, DO, ATTACH, sqlite3 dot-commands, - transaction control, multi-statement input), psql meta-commands, - EXPLAIN ANALYZE, data-modifying CTE bodies, SELECT INTO, CALL, dblink, - load_extension, writable PRAGMA, and other unanalyzable forms are blocked - fail-closed. PostgreSQL reads run in a read-only transaction; SQLite reads - open the file URI with mode=ro. Direct psql/pgcli/sqlite3 invocations in - run/command mode are blocked — use sshx sql. Every invocation is audited - with a literal-redacted statement, its exact SHA-256 digest, classification, - backup, and outcome. - - --engine=postgres|sqlite|mysql SQL engine (default: postgres) - --db=NAME PostgreSQL database name, or SQLite path if --db-file is omitted - --db-file=PATH Absolute SQLite database file path (required for --engine=sqlite) - --db-user=USER Database role (default: remote psql default; sqlite unused) - --db-host=HOST Database host as seen from the remote (default: local socket) - --db-port=PORT Database port - --db-password-key=KEY Keyring key for the DB password; delivered via stdin, - never via argv (implies --db-host=127.0.0.1 when unset) - --docker=CONTAINER Run psql inside this container via docker exec -i - (default connection becomes the container-local socket; - backups still land on the host) - --db-cred-from=SOURCE Resolve DB credentials on the remote host instead of the - local keyring: docker: (container env) or - env-file: (KEY=VALUE file). Recognizes PG*, - POSTGRES_*, DB_* keys and DATABASE_URL. Mutually - exclusive with --db-password-key; --db becomes optional - when the source provides the database name. - --cred-cache=off|DURATION Temporary local cache for remotely resolved credentials - (default: 15m). The secret lives only in the secret backend; - ~/.sshx/sql-cred-cache.json records identity + expiry. - Expired entries are deleted from the keyring. - --cred-refresh Drop the cached entry and re-resolve from the source - --explain Run EXPLAIN only; never executes the statement - --row-threshold=N EXPLAIN row estimate that upgrades a row backup to a - full-table CSV snapshot (default: 1000) - --allow-full-table Required for UPDATE/DELETE without a WHERE clause - --no-backup Skip pre-change backup (requires --force) - --backup-dir=PATH Remote backup directory (default: ~/.sshx/sql-backups) - --sudo Run sqlite3/psql via sudo -S (SSH user cannot open the file) - --force, -f Confirms DDL; destructive DDL also requires --no-backup - - Safety pipeline for data changes: classify locally (fail-closed), gate by - policy, then snapshot and execute. PostgreSQL runs EXPLAIN (FORMAT JSON) - and snapshots rows or the table under one transaction plus a SHARE ROW - EXCLUSIVE lock. SQLite skips row estimates and snapshots the table (CSV) - or the whole file under BEGIN IMMEDIATE. Whole-file .backup uses a second - read-only client while the mutation client holds the writer lock; mutation - is sent only after snapshot completion. SELECT and other reads skip EXPLAIN - and backups. - Catalog preflight blocks automatic execution when triggers, rewrite rules, - partitions, or cascading referential actions can affect related tables; - proceed only after an independent backup with --force --no-backup. Automatic - backups are not claimed for destructive DDL, which also requires - --force --no-backup. Backup directories/files are owner-only. --dry-run - previews the local plan without connecting; runtime catalog checks may block. - Row counts are engine-specific evidence, not universal proof of value changes. - Commit acknowledgement and verification are separate; uncertain commits need - inspection before retry. MySQL atomicity requires a supported, proven strategy: - separate-session backup/mutation and implicit-commit DDL are not atomic. - Bound SQL cannot depend on remote credential/container identity discovery. - SQL evidence.verification=protocol_verified acknowledges the client protocol, - not actual changed values; effect_verification remains separate. - Mutation state_change stays unknown, including zero affected rows. - Missing/malformed evidence reports protocol_error or verification_failed. - Guarded MySQL supports simple InnoDB single-table UPDATE/DELETE with a write - lock. Its SSHX_MYSQL_HEX_ROWS_V1 backup preserves NULL/binary values and is - not CSV, a schema dump, or automatic restore; no backup table/DDL is created. - - sshx sql -h=db1 --db=app "SELECT count(*) FROM users" - sshx sql -h=db1 --db=app --db-user=app --db-password-key=app-db \ - "UPDATE users SET active=false WHERE id=42" - sshx sql -h=db1 --db=app --explain "DELETE FROM sessions WHERE expires_at < now()" - sshx sql -h=db1 --db=app --force "TRUNCATE staging_events" - # Dockerized production DB: credentials live in the container env, psql runs - # inside the container, resolved credentials are cached in the keyring for 15m - sshx sql -h=prod --docker=pg-prod --db-cred-from=docker:pg-prod \ - "UPDATE users SET active=false WHERE id=42" - sshx sql -h=prod --docker=pg-prod --db-cred-from=env-file:/opt/app/.env \ - --cred-cache=1h "SELECT count(*) FROM orders" - sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \ - "UPDATE users SET active=0 WHERE id=42" - sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --sudo --json \ - "UPDATE users SET active=0 WHERE id=42" - -MikroTik RouterOS (ROS) over SSH: - sshx ros -h= [options] [key=value ...] - sshx ros -h= interface print [--json] - sshx ros -h= ip address add address=192.168.88.1/24 interface=ether1 - sshx ros -h= raw "/system/resource/print" - sshx ros -h= file upload - sshx ros -h= file download - sshx ros -h= backup download [--name=] [--cleanup] - sshx ros -h= export download [--compact] [--cleanup] - sshx ros -h= import [--cleanup] - sshx ros -h= script put --source=@ - sshx ros commands [--json] # List supported RouterOS commands - sshx ros help [command] [--json] # Show command help and arguments - sshx ros schema [command] [--json] # Emit JSON schema for command - sshx ros doctor [-h=] [--include-remote] # Health & environment check - sshx ros explain-error # Explain error code and remediation - - ROS Options: - --dry-run Emit execution plan JSON without modifying router - --allow-write Permit raw commands or mutations to alter state - --force, -f Bypass safety guardrails for destructive commands - --raw Execute without CLI response parsing - --ros-version=VER RouterOS major version hint (v6, v7, auto) - --source=@PATH Local .rsc script file for script put - --cleanup Delete temporary remote files after workflow - --compact Use compact export for export download - --name=NAME Custom backup file name - -Guarded File Apply: - sshx apply -h= --path=/abs/remote.conf --from=./local.conf [options] - sshx apply --target= --path=/abs/remote.conf --from=./local.conf --json - - Replaces one remote regular file. sshx reads the current file, optionally - checks --expect-sha256, writes an owner-only backup, then atomically replaces - the target while preserving mode and owner. Reload/restart is not part of - this command — run a separate sshx run after apply succeeds. - - --path=PATH Absolute remote file path (required) - --from=PATH Local source file (required) - --expect-sha256=HEX Fail closed unless the current remote hash matches - --no-backup Skip the pre-change copy (requires --force) - --backup-dir=PATH Remote backup directory (default: ~/.sshx/file-backups) - --sudo Stage the payload over SFTP, then install with sudo - --force, -f Skip the hash precondition; required with --no-backup - --bypass-reason=TEXT Required with --force to overwrite /etc/passwd, - /etc/shadow, or /etc/sudoers - - JSON fields to branch on: success, change_state, executed, verified, - verification, completion, error_kind (legacy changed/created remain) - (precondition/blocked/remote_io/config/...), before_sha256, after_sha256, - backup.path, rollback_available. Identical content is success with - changed=false and does not write a backup. - Post-write verification_failed can mean the target already changed; inspect - hashes/backup before retry. SFTP hash rechecks are not arbitrary-writer CAS. - - sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ - --expect-sha256= --sudo --json - sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ - --dry-run --json - -Text Dissection: - sshx text --help - sshx text --help --json - sshx text -h= --path=/abs/file.log [options] - sshx text -h= --journal=UNIT [options] - - Bounded remote text/log anatomy for Agents. Do not wrap grep/journalctl - in sshx run for incident triage — text returns structured hits. - - Workflow: - 1. sshx text --help (or --help --json) - 2. --preset=exception --json (blocks and counts first) - 3. --around-line= --context=5 --json - 4. --download only for incident archives - - --path=/abs/file Stream a remote regular file over SFTP (default - scan=end, last 8MiB). Symlinks/dirs refused. - --journal=UNIT sshx-owned journalctl for one systemd unit - --preset=exception,error,panic,oom,http5xx - --pattern=RE2 Optional linear-time regexp after presets - --context=N Neighbor lines (0..20) - --around-line=L Slice around a previous hit - --offset=L --limit=N Line window inside the scanned bytes - --tail=N Last N lines of the scanned window - --scan=start|end File origin (default end) - --since= --until= Journal time bounds (no shell metacharacters) - --max-hits=N --max-bytes=N --max-scan-bytes=N - --sudo Read with sudo -S - --no-redact Keep secret-shaped spans (default redacts) - - JSON schema sshx.text.v1: hits[], stats.total_hits vs returned, - truncated, truncated_reason, line_origin, redacted. - There is no --command; that is sshx run. - - sshx text -h=prod-web --path=/var/log/nginx/error.log --preset=exception --json - sshx text -h=prod-web --journal=nginx.service --since=1h --preset=error --json - -Interactive Login: - sshx login [--sudo] - sshx login -h= [-u=] [-i=] [--sudo] - sshx login --address= [--sudo] - sshx login --dry-run --json - - Human-only escape hatch onto a host already known to sshx. It opens one - interactive session and attaches the local TTY. This is not an Agent - contract: --json is only valid with --dry-run, multi-host selectors are - rejected, and login is not exposed over MCP. - - / -h=HOST Named host or hostname (exactly one) - --target=NAME Long alias of -h= / - --address=HOST Literal address; skip settings.json resolution - --sudo Land in a privileged login shell (sudo -i) - using the host sudo keyring secret on stdin - --dry-run / --json Local plan only; --json requires --dry-run - - Requires a local TTY. POSIX only; Windows returns an explicit unsupported - error. There is no command timeout and no session transcript. Audit records - target, auth, sudo, duration, and exit code. - - sshx login prod-web - sshx login prod-web --sudo - sshx login -h=prod-web --sudo - sshx login prod-web --dry-run --json - -Plugin Management: - sshx plugin create [--runner=sh] [--platform=linux|darwin] - [--privilege=never|optional|required] - [--template=generic|docker|nginx] [--replace] [--json] - sshx plugin list [--json] - sshx plugin show [--json] - sshx plugin validate [--json] - sshx plugin test [--fixture=] [--json] - sshx plugin trust [--json] - sshx plugin remove [--json] - - Local plugins belong to sshx, not to an Agent skill. They are stored under - ~/.sshx/plugins/ and remain untrusted until their current digest is - explicitly trusted. Editing a trusted manifest, schema, or collector changes - the digest and blocks remote execution until it is trusted again. - -Agent Skill Installation: - sshx skill install [--dir=] [--force] [--json] - - The canonical sshx Agent skill is embedded in the binary, so installation - does not need a network download or a release archive next to the executable. - The default target is ~/.agents/skills/sshx/SKILL.md. Pass --dir to select - another sshx skill directory. - - A matching installed skill is left unchanged (or repaired to mode 0644). - A prior sshx-managed version is updated using its digest sidecar. Differing - unmanaged content is preserved unless --force is explicit. Symlinked targets - are rejected. JSON status is installed, current, repaired, or updated; - failures use conflict, unsafe_target, or install_error. - -Environment Variables (.env): - SSH_PASSWORD SSH password (not recommended, use SSH keys or keyring) - SSH_KEY_PATH SSH private key path - SSH_SUDO_KEY Sudo password keyring key name (default: master) - SSH_NO_SAFETY_CHECK Disable safety checks (true/false) - SSH_FORCE Force execution mode (true/false) - SSH_TIMEOUT Command execution timeout (e.g. 30s, 2m, or 30 = seconds) - SSHX_AUDIT_OUTPUT Audit output directory (default: ~/.sshx/audit) - SSHX_NO_AUDIT Disable audit writing (true/false) - SSHX_HOME Override the local sshx runtime root (default: ~/.sshx) - SSHX_SECRET_BACKEND Secret store: keyring (default) or local-vault - SSHX_VAULT_PASSPHRASE Unlock passphrase for local-vault (unattended) - SSHX_VAULT_KEY_FILE 0600 file containing the vault passphrase (wins over env) - -SSH Examples: - # Execute simple command (default user: master) - sshx -h=192.168.1.100 "uptime" - - # Execute sudo command (auto password from keyring: master) - sshx -h=192.168.1.100 "sudo systemctl status docker" - - # Use custom sudo password key for specific server - sshx -h=192.168.1.100 -pk=server-A "sudo systemctl restart nginx" - sshx -h=192.168.1.101 -pk=server-B "sudo systemctl restart nginx" - - # Custom SSH port - sshx -h=192.168.1.100 -p=2222 "ps aux | grep nginx" - - # Bind the local source address (IP or interface name) - sshx -h=prod-web --bind=en0 "uptime" - - # Structured JSON output for scripts/agents (one object on stdout) - sshx -h=192.168.1.100 --json "systemctl is-active nginx" - - # Preview the execution plan without connecting or reading secrets - sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx" - - # Save audit events for this project - sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx" - - # Bound the command wait (does not guarantee remote termination) - sshx -h=192.168.1.100 --timeout=30s "apt-get update" - - # Dangerous command will be blocked - sshx -h=192.168.1.100 "sudo rm -rf /tmp/*" # Safe - sshx -h=192.168.1.100 "sudo rm -rf /" # ⚠️ BLOCKED! - - # Force execute (bypass safety check - use with caution!) - sshx -h=192.168.1.100 --force "sudo reboot" - sshx -h=192.168.1.100 -f "sudo systemctl reboot" - -Inspection Examples: - # Inspect stable system/network state in one invocation - sshx inspect -h=prod-web system.baseline --json - - # Create a locally editable Docker capability, validate it, then trust it - sshx plugin create docker.environment --template=docker --privilege=optional --json - sshx plugin validate docker.environment --json - sshx plugin test docker.environment --fixture=complete --json - sshx plugin trust docker.environment --json - - # Inspect once and persist only the redacted observation on the target - sshx inspect -h=prod-web docker.environment --cache=remote-prefer --json - -Agent Skill Example: - # Install after Homebrew/go install, or refresh after upgrading sshx - sshx skill install - - # Replace a locally modified copy after reviewing the difference - sshx skill install --force --json - -SFTP Examples: - # Upload file - sshx -h=192.168.1.100 --upload=local.txt --to=/tmp/remote.txt - - # Download file - sshx -h=192.168.1.100 --download=/var/log/app.log --to=./app.log - - # List directory - sshx -h=192.168.1.100 --list=/var/log - - # Create directory - sshx -h=192.168.1.100 --mkdir=/tmp/newdir - - # Remove file - sshx -h=192.168.1.100 --rm=/tmp/oldfile.txt - - # Batch upload - for file in *.txt; do - sshx -h=192.168.1.100 --upload=$file --to=/backup/$file - done - -Server-to-Server Transfer Examples: - # Transfer a file directly between two servers (by IP) - sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log - - # Transfer between configured hosts (from settings.json) - sshx --transfer=prod-web:/etc/nginx/nginx.conf --to=staging-web:/etc/nginx/nginx.conf - - # Transfer a whole directory recursively - sshx --transfer=prod-db:/var/backups --to=backup-server:/mnt/archive/db - - # If the destination is an existing directory, the source is placed inside it - sshx --transfer=prod-web:/var/log/app.log --to=log-server:/var/logs/ - - # Preview the transfer plan without connecting - sshx --transfer=prod-web:/data --to=prod-db:/data --dry-run - -Password Management Examples: - # Set default sudo password (interactive prompt) - sshx --password-set=master - - # Set sudo password (inline, not recommended for security) - sshx --password-set=master:mypassword - - # Set passwords for different servers with same username - sshx --password-set=server-A - sshx --password-set=server-B - sshx --password-set=server-C - - # Use different password keys for different servers - sshx -h=192.168.1.100 -pk=server-A "sudo systemctl status nginx" - sshx -h=192.168.1.101 -pk=server-B "sudo systemctl status nginx" - sshx -h=192.168.1.102 -pk=server-C "sudo systemctl status nginx" - - # Set password for specific user - sshx --password-set=root - sshx --password-set=admin - - # Get password from OS keyring (refused when using local-vault) - sshx --password-get=master - - # Headless server: encrypted local vault (write-only; Agent never sees the value) - SSHX_SECRET_BACKEND=local-vault SSHX_VAULT_PASSPHRASE='…' \ - sshx --password-set=prod-web - SSHX_SECRET_BACKEND=local-vault SSHX_VAULT_PASSPHRASE='…' \ - sshx --password-check=prod-web - - - # Check if password exists - sshx --password-check=server-A - - # List common password keys - sshx --password-list - - # Delete password from keyring - sshx --password-delete=server-A - -Host Management Examples: - # Add host interactively - sshx --host-add - - # Add host with command line options - sshx --host-add --host-name=prod-web -h=192.168.1.100 -u=root -pk=prod-web --host-desc="Production Web Server" - - # Add host with its own SSH private key - sshx --host-add --host-name=prod-db -h=192.168.1.200 -u=admin -i=~/.ssh/prod-db.pem +// helpSchemaVersion is the schema of the generic per-verb help document. +const helpSchemaVersion = "sshx.help.v1" + +// usageSection is one titled block of the sshx help surface. `sshx --help` +// prints every block in order; `sshx --help` prints that verb's blocks +// plus the shared option blocks. The text is defined once, in +// usage_sections.go, so the per-verb help cannot drift from the global help. +type usageSection struct { + title string + verb string // empty for blocks that no single verb owns + summary string // one line, emitted by sshx.help.v1 + text string +} - # Persist a source bind for a named host - sshx --host-add --host-name=edge --bind=en0 -h=100.117.253.247 -p=18922 +// helpVerbs are the subcommands that answer `sshx --help`. +var helpVerbs = []string{"run", "apply", "sql", "text", "inspect", "plugin", "skill", "audit", "login", "mcp", "ros"} + +// verbSharedSections are the blocks every remote verb accepts, repeated in each +// per-verb help so one call answers "what may I pass here?". +var verbSharedSections = []string{ + "SSH Options", + "Agent / Scripting Mode", + "Sudo Auto-fill", + "Dry-run Plan Preview", + "Audit Trail", + "Safety Options", +} - # Update host IP address - sshx --host-update --host-name=prod-web -h=192.168.1.101 +// usageSections returns every help block in global-help order. +func usageSections() []usageSection { + return []usageSection{ + {title: "Usage", verb: "", summary: "", text: usageIntro}, + {title: "SSH Options", verb: "", summary: "", text: usageSSHOptions}, + {title: "Run Contract (preferred for Agents)", verb: "run", summary: "Execute one command or script across selected hosts with the versioned sshx.run contract.", text: usageRun}, + {title: "Agent / Scripting Mode", verb: "", summary: "", text: usageAgentMode}, + {title: "Sudo Auto-fill", verb: "", summary: "", text: usageSudoAutoFill}, + {title: "Dry-run Plan Preview", verb: "", summary: "", text: usageDryRun}, + {title: "Audit Trail", verb: "", summary: "", text: usageAuditTrail}, + {title: "Audit Query and Export", verb: "audit", summary: "Query or export the local structured audit trail without connecting or writing.", text: usageAuditQuery}, + {title: "Safety Options", verb: "", summary: "", text: usageSafety}, + {title: "SFTP Options", verb: "", summary: "", text: usageSFTP}, + {title: "Server-to-Server Transfer", verb: "", summary: "", text: usageTransfer}, + {title: "Password Management (Cross-Platform)", verb: "", summary: "", text: usagePassword}, + {title: "Host Management", verb: "", summary: "", text: usageHost}, + {title: "Inspection Capabilities", verb: "inspect", summary: "Collect or reuse one structured host observation from a built-in or local capability.", text: usageInspect}, + {title: "Guarded SQL Execution", verb: "sql", summary: "Run one guarded SQL statement through the database client already present on the remote host.", text: usageSQL}, + {title: "MikroTik RouterOS (ROS) over SSH", verb: "ros", summary: "Manage MikroTik RouterOS devices over SSH and SFTP with structured commands.", text: usageROS}, + {title: "Guarded File Apply", verb: "apply", summary: "Replace one remote regular file with a precondition, backup, and post-write verification pipeline.", text: usageApply}, + {title: "Text Dissection", verb: "", summary: "", text: usageText}, + {title: "Interactive Login", verb: "login", summary: "Open one human interactive TTY session on a host already known to sshx.", text: usageLogin}, + {title: "Plugin Management", verb: "plugin", summary: "Install, create, list, validate, trust, and remove local inspection plugins.", text: usagePlugin}, + {title: "Agent Skill Installation", verb: "skill", summary: "Install or update the sshx Agent skill embedded in the binary.", text: usageSkill}, + {title: "MCP Server (stdio)", verb: "mcp", summary: "Serve the sshx execution contract over stdio to an MCP client.", text: usageMCP}, + {title: "Environment Variables (.env)", verb: "", summary: "", text: usageEnv}, + {title: "SSH Examples", verb: "", summary: "", text: usageSSHExamples}, + {title: "Inspection Examples", verb: "inspect", summary: "", text: usageInspectExamples}, + {title: "Agent Skill Example", verb: "skill", summary: "", text: usageSkillExample}, + {title: "SFTP Examples", verb: "", summary: "", text: usageSFTPExamples}, + {title: "Server-to-Server Transfer Examples", verb: "", summary: "", text: usageTransferExamples}, + {title: "Password Management Examples", verb: "", summary: "", text: usagePasswordExamples}, + {title: "Host Management Examples", verb: "", summary: "", text: usageHostExamples}, + {title: "Note", verb: "", summary: "", text: usageNotes}, + } +} - # Update host SSH key - sshx --host-update --host-name=prod-web -i=~/.ssh/new-key.pem +// PrintUsage prints the global sshx help surface. +func PrintUsage() { + fmt.Printf("\nSSHX — Agent-native remote host execution over SSH\nVersion: %s\n", Version) + for _, section := range usageSections() { + fmt.Print(section.text) + } + fmt.Println() +} - # Update host password key - sshx --host-update --host-name=prod-web -pk=new-password-key +// PrintVerbUsage answers `sshx --help`: the verb's own blocks, the +// shared option blocks, and a pointer to the global surface. With --json it +// emits the sshx.help.v1 document instead of prose. `sshx text` keeps its +// dedicated Agent-facing document and its own sshx.text.help.v1 schema. +func PrintVerbUsage(config *sshclient.Config) error { + verb := config.HelpVerb + if verb == "text" { + return HandleTextHelp(config) + } + sections := verbUsageSections(verb) + if len(sections) == 0 { + return fmt.Errorf("%w: unknown help verb %q (known verbs: %s)", execution.ErrConfig, verb, strings.Join(helpVerbs, ", ")) + } + if config.JSONOutput { + document := verbHelpDocument{ + SchemaVersion: helpSchemaVersion, Verb: verb, + Summary: verbSummary(sections), Usage: helpDocumentSections(sections), + } + if err := encodeJSON(document); err != nil { + return fmt.Errorf("%w: deliver help: %w", execution.ErrLocalIO, err) + } + return nil + } + fmt.Print(renderVerbUsage(verb, sections)) + return nil +} - # Update multiple fields - sshx --host-update --host-name=prod-web -h=192.168.1.101 -u=admin -pk=new-key +// verbHelpDocument is the machine-readable form of a per-verb help surface. +type verbHelpDocument struct { + SchemaVersion string `json:"schema_version"` + Verb string `json:"verb"` + Summary string `json:"summary"` + Usage []verbHelpSection `json:"usage"` +} - # List all configured hosts - sshx --host-list +type verbHelpSection struct { + Title string `json:"title"` + Text string `json:"text"` +} - # Test connection to a configured host - sshx --host-test=prod-web +// verbUsageSections returns the blocks printed by `sshx --help`: the +// verb's own blocks first, then the shared option blocks. An unknown verb +// returns nil so the caller can report it. +func verbUsageSections(verb string) []usageSection { + if !containsString(helpVerbs, verb) { + return nil + } + var sections []usageSection + for _, section := range usageSections() { + if section.verb == verb { + sections = append(sections, section) + } + } + for _, section := range usageSections() { + if section.verb == "" && containsString(verbSharedSections, section.title) { + sections = append(sections, section) + } + } + return sections +} - # Test all configured hosts and get a report with auth methods - sshx --host-test-all +func verbSummary(sections []usageSection) string { + for _, section := range sections { + if section.summary != "" { + return section.summary + } + } + return "" +} - # Remove a host from configuration - sshx --host-remove=prod-web +func helpDocumentSections(sections []usageSection) []verbHelpSection { + document := make([]verbHelpSection, 0, len(sections)) + for _, section := range sections { + document = append(document, verbHelpSection{Title: section.title, Text: strings.TrimRight(section.text, "\n")}) + } + return document +} - # Use configured host (looks up from settings if not an IP) - sshx -h=prod-web "uptime" +func renderVerbUsage(verb string, sections []usageSection) string { + var out strings.Builder + fmt.Fprintf(&out, "sshx %s — %s\n\n", verb, verbSummary(sections)) + for _, section := range sections { + if section.verb == verb { + out.WriteString(section.text) + } + } + out.WriteString("Shared options and semantics:\n") + for _, section := range sections { + if section.verb == "" { + out.WriteString(section.text) + } + } + fmt.Fprintf(&out, "See also:\n %-28s full help surface\n %-28s this document as %s\n", + "sshx --help", "sshx "+verb+" --help --json", helpSchemaVersion) + return out.String() +} -Note: - - SSH key authentication is tried first; password auth is used only when SSH_PASSWORD is provided - - Sudo password is auto-filled only when the remote command starts with sudo - - Dry-run never connects, executes, reads keyring secrets, or writes state - - Audit events are JSONL files under ~/.sshx/audit by default - - SFTP operations use the same SSH connection - - Password manager works across macOS/Linux/Windows - - Default user: master, Default sudo key: master - - Host configurations are stored in ~/.sshx/settings.json`) +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false } // PrintTextUsage is the dedicated Agent-facing help for sshx text. @@ -793,14 +235,6 @@ There is no --command. JSON schema is sshx.text.v1. Branch on success, hits[].kind, stats.total_hits vs returned, truncated, truncated_reason, and line_origin (file vs scanned_window). -Monitoring: - A scan that runs longer than a few seconds narrates progress on stderr - (bytes, percentage, lines, elapsed, matches). Long or budget-limited scans - close with advice naming --offset/--tail/--max-scan-bytes. stderr carries - these lines only: stdout stays exactly one JSON document. stats gains - expected_scan_bytes (the window budget) alongside file_size/window_start_byte, - so a caller can size a scan before trusting total_hits_exact. - Examples: sshx text -h=prod-web --path=/var/log/nginx/error.log --preset=exception --json sshx text -h=prod-web --journal=nginx.service --since=1h --preset=error --json diff --git a/internal/app/usage_sections.go b/internal/app/usage_sections.go new file mode 100644 index 0000000..aac5095 --- /dev/null +++ b/internal/app/usage_sections.go @@ -0,0 +1,884 @@ +package app + +// Help text for the sshx command surface. +// +// Each block is defined exactly once: `sshx --help` prints every block in +// order, and `sshx --help` prints that verb's blocks plus the shared +// option blocks. Add or change text here, never in a second place. + +const usageIntro = ` +Usage: + sshx -h= [options] # SSH mode (compatibility) + sshx run [selectors] [options] -- # Canonical execution contract + sshx run --script-file=PATH ... # Byte-preserving script payload + sshx -h= [options] --upload= # SFTP upload + sshx -h= [options] --download= # SFTP download + sshx --transfer=: --to=: # Server-to-server transfer + sshx --password-set=[:] # Store a password (keyring or local vault) + sshx --password-get= # Get password from OS keyring (denied for local vault) + sshx --password-delete= # Delete a stored password + sshx --password-list # List stored or common password keys + sshx --host-add # Add host configuration + sshx --host-update # Update host configuration + sshx --host-list # List configured hosts + sshx --host-test= # Test host connection + sshx --host-test-all # Test all host connections + sshx --host-remove= # Remove host configuration + sshx skill install [options] # Install/update the bundled Agent skill + sshx plugin create [options] # Scaffold a local inspection plugin + sshx plugin list [--json] # List built-in and local capabilities + sshx inspect -h= [options] # Run one structured host inspection + sshx sql -h= --db= [options] "SQL" # Guarded SQL via remote psql/sqlite3 + sshx ros -h= [options] # MikroTik RouterOS over SSH + sshx apply -h= --path= --from= # Guarded remote file apply + sshx text -h= --path= [options] # Bounded remote text/log dissection + sshx login [--sudo] # Human interactive login (TTY required) + sshx mcp # Serve the execution contract over stdio (MCP) + sshx audit query [filters] [--json] # Read-only audit trail query + sshx audit export --to= [filters] # Export matching audit events as JSONL + sshx --help # Per-verb usage (add --json for sshx.help.v1) +` + +const usageSSHOptions = ` +SSH Options: + -h, --host=HOST Remote host address (required in compatibility mode) + -p, --port=PORT SSH port (default: 22) + -u, --user=USER SSH username (default: master) + -i, --key=PATH SSH private key path (default: ~/.ssh/id_rsa) + -pk, --password-key=KEY Sudo password keyring key name (default: master) + Used only when the remote command starts with sudo + --ssh-password-key=KEY SSH login password keyring key (never used for sudo) + --bind=ADDR Local source IP or interface (e.g. 192.0.2.10 or en0) + --via=NAME Named sshx jump host (session-bound; no local tunnel) + --dry-run Print the local execution plan without side effects + --expect-plan=HASH Require the reviewed sha256:<64 lowercase hex> plan + --audit-output=DIR Write audit JSONL files to DIR (default: ~/.sshx/audit) + --no-audit Disable local audit event writing for this invocation + --timeout=DURATION Command execution timeout (e.g. 30s, 2m, or 30 = seconds) + --host-timeout=DURATION Optional total admitted-target budget (setup + verify) + --global-timeout=DURATION Optional operation budget, including queued targets + --json Emit a single structured JSON result on stdout + --quiet, --no-notices Suppress human notices on stderr (deprecation warnings, + narration). stdout, the exit code, and the JSON + document are unchanged + --pty Request a PTY (merges stderr into stdout; off by default) + --version Show version information (alias: -v) + --help Show this help message +` + +const usageRun = ` +Run Contract (preferred for Agents): + 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 + cat ./check.sh | sshx run --target=prod-web --script-stdin --json + + Selectors (configured hosts only; multi-host never treats names as DNS): + --target=NAME strict alias (repeatable via --targets=a,b) + --group=NAME union with other names/groups (repeatable) + --tag=key=value AND filter (repeatable) + --all-hosts all configured hosts before tag filters + --address=HOST explicit single literal address (not for fan-out) + + Script payloads: + --script-file=PATH byte-preserving script from a local file + --script-stdin byte-preserving script from stdin + --shell=NAME interpreter override: sh, bash, zsh, dash, ksh, ash + (default: the script's #! line, else sh) + + Limits / policy: + --concurrency=N default 4, hard max 32 + --failure-mode=continue|fail_fast default continue + --fail-fast Alias of --failure-mode=fail_fast + --max-failures=N Stop new admission at this failure threshold + --intent=read|change|unknown + --force / --no-safety-check require --bypass-reason=TEXT + --jsonl stream run_started/target_*/run_finished events + + Failure thresholds stop admission only; already admitted targets finish and + can add failures. Conflicting failure policies are configuration errors. + New time budgets are opt-in; --timeout keeps its existing meaning/defaults. + Cancellation closes local transport work, not guaranteed remote termination + or rollback. Treat unacknowledged changes as uncertain before retrying. + + Multi-target exit codes: + 0 all selected targets succeeded + 1 run accepted but at least one target failed/skipped/uncertain + 255 request-level failure (bad selectors, zero matches, invalid input) +` + +const usageAgentMode = ` +Agent / Scripting Mode: + By default command output streams live with stdout and stderr kept on + separate channels (no PTY), and the remote command's exit status is + propagated as sshx's own exit code. + + Compatibility --json emits one JSON object on stdout: + {host, port, user, command, exit_code, success, stdout, stderr, + stdout_truncated, stderr_truncated, duration_ms, auth_method, + error_kind, error} + + Under --json, stdout carries the machine document and only the machine + document; sshx never writes human text there, including on failure. Human + notices (deprecation warnings, narration) go to stderr. A caller that merges + the streams (2>&1) should pass --quiet, which suppresses the notices so the + merged stream is still parseable; failures keep their JSON error_kind/error. + sshx run --json adds versioned fields (schema_version, run_id, status, + phase, completion, structured error). + + Shared Execution Evidence: + plan_hash, risk, effects, execution_id, parent_execution_id, + execution_fingerprint, change_state, executed (nullable), verified, + verification, preconditions, postconditions. + change_state is changed|unchanged|unknown; null executed is not false. + Success, execution acknowledgement, change, and verification are distinct. + Unknown commands/scripts default to mutation risk with unknown effects; + --intent=read is not proof of read-only behavior. Risk is + read|mutation|privileged|destructive, not an authorization grant. + Raw stdout/stderr and secret values are not execution-fingerprinted. + + Exit codes (single-host compatibility mode): + 0 command succeeded + 1..254 remote command's exit status (propagated verbatim) + 255 sshx-level failure (connect/auth/host-key/timeout/blocked/...) + In --json mode an sshx-level failure has exit_code -1 and a non-empty + error_kind (timeout, auth, host_key, connect, blocked, exit_missing, + config, error), so it is always distinguishable from a remote exit 255. + + A policy block also writes its reason to stderr + (exit_code=-1, error_kind=blocked, phase=admission, executed=false), so a + caller that only prints the streams sees why nothing ran. Use the guarded + alternative instead: sshx sql -h= --db= [--docker=] + "". stdout still carries exactly one JSON document. + + Trust note: high-risk bypasses (force, no-safety-check, accept-unknown-host, + insecure-hostkey) require explicit CLI flags. Inherited env values and + working-directory .env files are ignored for those decisions. +` + +const usageSudoAutoFill = ` +Sudo Auto-fill: + sshx auto-fills a sudo password only when the remote command starts with + sudo, for example: + sshx -h=host "sudo systemctl status nginx" + + Non-leading sudo is not auto-filled and does not trigger keyring lookup: + sshx -h=host "sh -c 'sudo whoami'" + sshx -h=host "echo sudo" + + sshx warns on stderr when it sees a non-leading sudo, because the remote then + stops with "sudo: a password is required". Wrap the privileged part instead: + sshx -h=host "sudo sh -c 'cd /data/app && docker compose up -d'" + + This keeps keyring lookup, stdin password injection, and future audit fields + on one clear rule. Put sudo at the beginning of the remote command when you + want sshx to auto-fill it. +` + +const usageDryRun = ` +Dry-run Plan Preview: + Add --dry-run to see how sshx would interpret an invocation before any + connection, command execution, keyring secret lookup, known_hosts mutation, or + settings write. Use --json with --dry-run for an agent-readable plan. + + Examples: + sshx -h=prod-web --dry-run "sudo systemctl restart nginx" + sshx -h=prod-web --dry-run --json --upload=local.txt --to=/tmp/local.txt + + Dry-run is a local plan preview only. It does not prove the remote command + would succeed. + + Bound Plan Admission: + Remote command/run/apply/sql/SFTP/transfer/inspect previews add a nested + sshx.plan.v1 plan plus plan_hash and risk. Run keeps sshx.request.v1 outside. + Repeat reviewed inputs with --expect-plan=sha256:<64 lowercase hex>. + Local mismatch fails before secret lookup or network work; --force cannot + bypass it. Errors: config (format), plan_mismatch, plan_unresolved. + Check plan.bindable and plan.unresolved: DNS-only addresses, unavailable + public-key sidecars, missing/relaxed trust, and remotely discovered SQL + identity cannot bind offline. The entire sorted known_hosts record snapshot + is hashed, so unrelated trust changes can conservatively invalidate a plan. + Dry-run never connects, reads secrets, or writes state. A plan hash is not + a lock on remote files, rows, permissions, or recursive directory membership. +` + +const usageAuditTrail = ` +Audit Trail: + sshx writes one structured JSONL audit event per non-dry-run invocation to + ~/.sshx/audit/sshx-YYYY-MM-DD.jsonl by default. Use --audit-output= to + save audit events next to a project or incident record. + + Audit events record metadata and outcomes such as mode/action, host + resolution, sudo/keyring decisions, safety status, auth method, exit code, and + error kind. They do not record plaintext passwords, private key contents, or + stdout/stderr. Command text is best-effort redacted for password/token-style + arguments. + + Read-only consumption: + sshx audit query --since=2026-09-01 --target=prod-web --json + sshx audit query --run-id= --error-kind=blocked --bypass-only + sshx audit query --execution-id= --json + sshx audit export --to=./incident.jsonl --since=2026-09-01 + Empty query results exit 0. JSON mode emits {schema_version, success, count, events}. + Corrupt/partial records have visible diagnostics; valid records are retained. + Audit writes are best-effort, with persistence status separate from execution. + Do not repeat a successful mutation just because audit writing failed. +` + +const usageSafety = ` +Safety Options: + -f, --force Force execution, bypass safety checks (use with caution!) + --no-safety-check Disable safety checks completely (not recommended) + --bypass-reason=TEXT Required with --force / --no-safety-check in command + mode and sshx run (recorded in dry-run, result, audit) + + Safety checks protect against: + - Destructive operations (rm -rf /, mkfs, dd) + - System shutdown/reboot commands + - Critical file modifications (/etc/passwd, /etc/shadow) + - Dangerous pipe operations (curl | sh) + - Fork bombs and other malicious patterns + - Direct database client execution (psql/pgcli/sqlite3/mysql/mariadb, + incl. docker exec, sudo -u, sh -c, kubectl exec wrappers) — use 'sshx sql' instead +` + +const usageSFTP = ` +SFTP Options: + --upload= Upload file (use with --to=) + --download= Download file (use with --to=) + --to= Target path for upload/download + --list= List directory contents (alias: --ls) + --mkdir= Create remote directory + --rm= Remove remote file or directory + + Add --json for structured operation/effect evidence. Size-only verification + does not prove content equality. Partial recursive progress is not rolled + back as one atomic directory operation. +` + +const usageTransfer = ` +Server-to-Server Transfer: + --transfer=: --to=: + + Streams files directly from one server to another through the local + machine (nothing is written to local disk). Supports single files and + recursive directory transfers, and preserves file permission bits. + Both hosts can be configured host names (from ~/.sshx/settings.json) + or IP addresses, each using its own SSH key/user/port from settings. +` + +const usagePassword = ` +Password Management (Cross-Platform): + --password-set=[:] Store a password in the secret backend + If password omitted, will prompt + --password-get= OS keyring: emit the value only when piped. + Local vault: always refused (write-only). + --password-check= Check if password exists (alias: --password-exists). + Missing keys exit non-zero. --json emits + sshx.secrets.v1 with success/exists. + --password-delete= Delete password (alias: --password-del) + --password-list List stored keys (vault) or common keys (keyring). + --json emits keys[] (list_complete=false on keyring probes) + + Default backend: OS keyring (macOS Keychain / Linux Secret Service / + Windows Credential Manager). Headless servers can opt into an encrypted + local vault with SSHX_SECRET_BACKEND=local-vault. There is no silent + fallback. The vault never displays secret values; sshx injects them over + stdin during execution. Unlock with SSHX_VAULT_PASSPHRASE or + SSHX_VAULT_KEY_FILE (0600). The vault file is $SSHX_HOME/vault. +` + +const usageHost = ` +Host Management: + --host-add Add new host (interactive or with options). + Omitting -pk does not persist sudo_password_key=master. + --json emits sshx.hosts.v1 for add/update/remove/test. + --host-import Selectively import hosts from ~/.ssh/config (interactive) + --host-import= Import only the named ssh_config hosts (non-interactive) + --ssh-config= ssh_config file to import from (default: ~/.ssh/config) + --host-update Update existing host configuration + --host-list List all configured hosts (alias: --host-ls) + --host-test= Test connection to configured host + --host-test-all Test connections for all configured hosts + --host-remove= Remove host from configuration (alias: --host-rm) + + Host Add/Update Options: + --host-name= Host name (unique identifier, required for update) + --host-desc= Host description + -h=
Host address (IP or hostname) + -p= SSH port + -u= SSH username + -i=, --key= SSH private key path for this host (optional) + -pk= Password key name + --host-type= System type (linux/windows/macos) + --bind= Persist a source address for this host + --via= Named jump host for this target + + Configuration file: ~/.sshx/settings.json +` + +const usageInspect = ` +Inspection Capabilities: + sshx inspect -h= [options] + + Built in: + system.identity, system.resources, system.baseline + network.interfaces, network.routes, network.dns + network.listeners, network.firewall + + --cache=off|remote-prefer Reuse/write a remote observation (default: off) + --refresh Ignore a reusable observation and run the collector + --max-age=DURATION Require observations no older than this duration + --allow-stale Explicitly allow an expired observation + --sudo Use sudo for an optional-privilege plugin + + Collectors execute once through SSH stdin and are never installed on the + target. With remote-prefer caching, only redacted JSON is stored below the + remote user's ~/.sshx/observations/v1 directory. +` + +const usageSQL = ` +Guarded SQL Execution: + sshx sql -h= --db= [options] "" + sshx sql -h= --engine=sqlite --db-file=/abs/path.db [options] "SQL" + sshx sql -h= --db= [options] -- + sshx sql -h= --db= [options] --statement-file=./query.sql + printf '%s' 'select 1' | sshx sql -h= --db= [options] + + The statement may be a positional argument, everything after --, a local file + via --statement-file=PATH, or stdin when it is piped rather than a terminal. + Reading stdin waits for EOF, so close stdin when another process holds the pipe + open, or pass --statement-file=PATH. + A statement that opens with a comment ("-- header") cannot be an option + token, so it is taken as statement text; only option-shaped typos are + rejected, and those name the intended option. + + Statements run through the database client already present on the remote + host (psql, sqlite3, or mysql/mariadb). sshx embeds no database driver and opens no tunnel. + Exactly one statement per invocation. Unknown or dangerous statement heads + (DROP DATABASE/SCHEMA, ALTER SYSTEM, COPY, DO, ATTACH, sqlite3 dot-commands, + transaction control, multi-statement input), psql meta-commands, + EXPLAIN ANALYZE, data-modifying CTE bodies, SELECT INTO, CALL, dblink, + load_extension, writable PRAGMA, and other unanalyzable forms are blocked + fail-closed. PostgreSQL reads run in a read-only transaction; SQLite reads + open the file URI with mode=ro. Direct psql/pgcli/sqlite3 invocations in + run/command mode are blocked — use sshx sql. Every invocation is audited + with a literal-redacted statement, its exact SHA-256 digest, classification, + backup, and outcome. + + --statement-file=PATH Read the statement from a local file (mutually + exclusive with a positional statement) + --engine=postgres|sqlite|mysql SQL engine (default: postgres) + --db=NAME PostgreSQL database name, or SQLite path if --db-file is omitted + --db-file=PATH Absolute SQLite database file path (required for --engine=sqlite) + --db-user=USER Database role (default: remote psql default; sqlite unused) + --db-host=HOST Database host as seen from the remote (default: local socket) + --db-port=PORT Database port + --db-password-key=KEY Keyring key for the DB password; delivered via stdin, + never via argv (implies --db-host=127.0.0.1 when unset) + --docker=CONTAINER Run psql inside this container via docker exec -i + (default connection becomes the container-local socket; + backups still land on the host) + --db-cred-from=SOURCE Resolve DB credentials on the remote host instead of the + local keyring: docker: (container env) or + env-file: (KEY=VALUE file). Recognizes PG*, + POSTGRES_*, DB_* keys and DATABASE_URL. Mutually + exclusive with --db-password-key; --db becomes optional + when the source provides the database name. + --cred-cache=off|DURATION Temporary local cache for remotely resolved credentials + (default: 15m). The secret lives only in the secret backend; + ~/.sshx/sql-cred-cache.json records identity + expiry. + Expired entries are deleted from the keyring. + --cred-refresh Drop the cached entry and re-resolve from the source + --explain Run EXPLAIN only; never executes the statement + --row-threshold=N EXPLAIN row estimate that upgrades a row backup to a + full-table CSV snapshot (default: 1000) + --allow-full-table Required for UPDATE/DELETE without a WHERE clause + --no-backup Skip pre-change backup (requires --force) + --backup-dir=PATH Remote backup directory (default: ~/.sshx/sql-backups) + --sudo Run sqlite3/psql via sudo -S (SSH user cannot open the file) + --force, -f Confirms DDL; destructive DDL also requires --no-backup + + Safety pipeline for data changes: classify locally (fail-closed), gate by + policy, then snapshot and execute. PostgreSQL runs EXPLAIN (FORMAT JSON) + and snapshots rows or the table under one transaction plus a SHARE ROW + EXCLUSIVE lock. SQLite skips row estimates and snapshots the table (CSV) + or the whole file under BEGIN IMMEDIATE. Whole-file .backup uses a second + read-only client while the mutation client holds the writer lock; mutation + is sent only after snapshot completion. SELECT and other reads skip EXPLAIN + and backups. + Catalog preflight blocks automatic execution when triggers, rewrite rules, + partitions, or cascading referential actions can affect related tables; + proceed only after an independent backup with --force --no-backup. Automatic + backups are not claimed for destructive DDL, which also requires + --force --no-backup. Backup directories/files are owner-only. --dry-run + previews the local plan without connecting; runtime catalog checks may block. + Row counts are engine-specific evidence, not universal proof of value changes. + Commit acknowledgement and verification are separate; uncertain commits need + inspection before retry. MySQL atomicity requires a supported, proven strategy: + separate-session backup/mutation and implicit-commit DDL are not atomic. + Bound SQL cannot depend on remote credential/container identity discovery. + SQL evidence.verification=protocol_verified acknowledges the client protocol, + not actual changed values; effect_verification remains separate. + Mutation state_change stays unknown, including zero affected rows. + Missing/malformed evidence reports protocol_error or verification_failed. + Guarded MySQL supports simple InnoDB single-table UPDATE/DELETE with a write + lock. Its SSHX_MYSQL_HEX_ROWS_V1 backup preserves NULL/binary values and is + not CSV, a schema dump, or automatic restore; no backup table/DDL is created. + + sshx sql -h=db1 --db=app "SELECT count(*) FROM users" + sshx sql -h=db1 --db=app --db-user=app --db-password-key=app-db \ + "UPDATE users SET active=false WHERE id=42" + sshx sql -h=db1 --db=app --explain "DELETE FROM sessions WHERE expires_at < now()" + sshx sql -h=db1 --db=app --force "TRUNCATE staging_events" + # Dockerized production DB: credentials live in the container env, psql runs + # inside the container, resolved credentials are cached in the keyring for 15m + sshx sql -h=prod --docker=pg-prod --db-cred-from=docker:pg-prod \ + "UPDATE users SET active=false WHERE id=42" + sshx sql -h=prod --docker=pg-prod --db-cred-from=env-file:/opt/app/.env \ + --cred-cache=1h "SELECT count(*) FROM orders" + sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \ + "UPDATE users SET active=0 WHERE id=42" + sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --sudo --json \ + "UPDATE users SET active=0 WHERE id=42" +` + +const usageROS = ` +MikroTik RouterOS (ROS) over SSH: + sshx ros -h= [options] [key=value ...] + sshx ros -h= interface print [--json] + sshx ros -h= ip address add address=192.168.88.1/24 interface=ether1 + sshx ros -h= raw "/system/resource/print" + sshx ros -h= file upload + sshx ros -h= file download + sshx ros -h= backup download [--name=] [--cleanup] + sshx ros -h= export download [--compact] [--cleanup] + sshx ros -h= import [--cleanup] + sshx ros -h= script put --source=@ + sshx ros commands [--json] # List supported RouterOS commands + sshx ros help [command] [--json] # Show command help and arguments + sshx ros schema [command] [--json] # Emit JSON schema for command + sshx ros doctor [-h=] [--include-remote] # Health & environment check + sshx ros explain-error # Explain error code and remediation + + ROS Options: + --dry-run Emit execution plan JSON without modifying router + --allow-write Permit raw commands or mutations to alter state + --force, -f Bypass safety guardrails for destructive commands + --raw Execute without CLI response parsing + --ros-version=VER RouterOS major version hint (v6, v7, auto) + --source=@PATH Local .rsc script file for script put + --cleanup Delete temporary remote files after workflow + --compact Use compact export for export download + --name=NAME Custom backup file name +` + +const usageApply = ` +Guarded File Apply: + sshx apply -h= --path=/abs/remote.conf --from=./local.conf [options] + sshx apply --target= --path=/abs/remote.conf --from=./local.conf --json + + Replaces one remote regular file. sshx reads the current file, optionally + checks --expect-sha256, writes an owner-only backup, then atomically replaces + the target while preserving mode and owner. Reload/restart is not part of + this command — run a separate sshx run after apply succeeds. + + --path=PATH Absolute remote file path (required) + --from=PATH Local source file (required) + --expect-sha256=HEX Fail closed unless the current remote hash matches + --no-backup Skip the pre-change copy (requires --force) + --backup-dir=PATH Remote backup directory (default: ~/.sshx/file-backups) + --sudo Stage the payload over SFTP, then install with sudo + --force, -f Skip the hash precondition; required with --no-backup + --bypass-reason=TEXT Required with --force to overwrite /etc/passwd, + /etc/shadow, or /etc/sudoers + + JSON fields to branch on: success, change_state, executed, verified, + verification, completion, error_kind (legacy changed/created remain) + (precondition/blocked/remote_io/config/...), before_sha256, after_sha256, + backup.path, rollback_available. Identical content is success with + changed=false and does not write a backup. + Post-write verification_failed can mean the target already changed; inspect + hashes/backup before retry. SFTP hash rechecks are not arbitrary-writer CAS. + + sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --expect-sha256= --sudo --json + sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --dry-run --json +` + +const usageText = ` +Text Dissection: + sshx text --help + sshx text --help --json + sshx text -h= --path=/abs/file.log [options] + sshx text -h= --journal=UNIT [options] + + Bounded remote text/log anatomy for Agents. Do not wrap grep/journalctl + in sshx run for incident triage — text returns structured hits. + + Workflow: + 1. sshx text --help (or --help --json) + 2. --preset=exception --json (blocks and counts first) + 3. --around-line= --context=5 --json + 4. --download only for incident archives + + --path=/abs/file Stream a remote regular file over SFTP (default + scan=end, last 8MiB). Symlinks/dirs refused. + --journal=UNIT sshx-owned journalctl for one systemd unit + --preset=exception,error,panic,oom,http5xx + --pattern=RE2 Optional linear-time regexp after presets + --context=N Neighbor lines (0..20) + --around-line=L Slice around a previous hit + --offset=L --limit=N Line window inside the scanned bytes + --tail=N Last N lines of the scanned window + --scan=start|end File origin (default end) + --since= --until= Journal time bounds (no shell metacharacters) + --max-hits=N --max-bytes=N --max-scan-bytes=N + --sudo Read with sudo -S + --no-redact Keep secret-shaped spans (default redacts) + + JSON schema sshx.text.v1: hits[], stats.total_hits vs returned, + truncated, truncated_reason, line_origin, redacted. + There is no --command; that is sshx run. + + Monitoring: + A scan that runs longer than a few seconds narrates progress on stderr + (bytes, percentage, lines, elapsed, matches). Long or budget-limited scans + close with advice naming --offset/--tail/--max-scan-bytes. stderr carries + these lines only: stdout stays exactly one JSON document. stats gains + expected_scan_bytes (the window budget) alongside file_size/window_start_byte, + so a caller can size a scan before trusting total_hits_exact. + + sshx text -h=prod-web --path=/var/log/nginx/error.log --preset=exception --json + sshx text -h=prod-web --journal=nginx.service --since=1h --preset=error --json +` + +const usageLogin = ` +Interactive Login: + sshx login [--sudo] + sshx login -h= [-u=] [-i=] [--sudo] + sshx login --address= [--sudo] + sshx login --dry-run --json + + Human-only escape hatch onto a host already known to sshx. It opens one + interactive session and attaches the local TTY. This is not an Agent + contract: --json is only valid with --dry-run, multi-host selectors are + rejected, and login is not exposed over MCP. + + / -h=HOST Named host or hostname (exactly one) + --target=NAME Long alias of -h= / + --address=HOST Literal address; skip settings.json resolution + --sudo Land in a privileged login shell (sudo -i) + using the host sudo keyring secret on stdin + --dry-run / --json Local plan only; --json requires --dry-run + + Requires a local TTY. POSIX only; Windows returns an explicit unsupported + error. There is no command timeout and no session transcript. Audit records + target, auth, sudo, duration, and exit code. + + sshx login prod-web + sshx login prod-web --sudo + sshx login -h=prod-web --sudo + sshx login prod-web --dry-run --json +` + +const usagePlugin = ` +Plugin Management: + sshx plugin install [--replace] [--trust] [--json] + sshx plugin create [--runner=sh] [--platform=linux|darwin] + [--privilege=never|optional|required] + [--template=generic|docker|nginx] [--replace] [--json] + sshx plugin list [--json] + sshx plugin show [--json] + sshx plugin validate [--json] + sshx plugin test [--fixture=] [--json] + sshx plugin trust [--json] + sshx plugin remove [--json] + + Local plugins belong to sshx, not to an Agent skill. They are stored under + ~/.sshx/plugins/ and remain untrusted until their current digest is + explicitly trusted. Editing a trusted manifest, schema, or collector changes + the digest and blocks remote execution until it is trusted again. + + plugin install is the audited way to provision an existing plugin directory: + it stages the source with sshx's own modes, validates it through the same + loader the executor uses, publishes it only if it is valid, and with --trust + records the published digest in one step. Symlinks and non-regular entries are + refused, and the copy is bounded (8MiB, 128 files). --replace preserves the + previous plugin under ~/.sshx/plugin-backups. + + plugin list groups built-in capabilities and local plugins and always names + the local root, so "no local plugins installed" is visible as such; a local + entry reports builtin/trusted/valid and its digest. A missing plugin names the + directory that was searched. +` + +const usageSkill = ` +Agent Skill Installation: + sshx skill install [--dir=] [--force] [--json] + + The canonical sshx Agent skill is embedded in the binary, so installation + does not need a network download or a release archive next to the executable. + The default target is ~/.agents/skills/sshx/SKILL.md. Pass --dir to select + another sshx skill directory. + + A matching installed skill is left unchanged (or repaired to mode 0644). + A prior sshx-managed version is updated using its digest sidecar. Differing + unmanaged content is preserved unless --force is explicit. Symlinked targets + are rejected. JSON status is installed, current, repaired, or updated; + failures use conflict, unsafe_target, or install_error. +` + +const usageEnv = ` +Environment Variables (.env): + SSH_PASSWORD SSH password (not recommended, use SSH keys or keyring) + SSH_KEY_PATH SSH private key path + SSH_SUDO_KEY Sudo password keyring key name (default: master) + SSH_NO_SAFETY_CHECK Disable safety checks (true/false) + SSH_FORCE Force execution mode (true/false) + SSH_TIMEOUT Command execution timeout (e.g. 30s, 2m, or 30 = seconds) + SSHX_AUDIT_OUTPUT Audit output directory (default: ~/.sshx/audit) + SSHX_NO_AUDIT Disable audit writing (true/false) + SSHX_HOME Override the local sshx runtime root (default: ~/.sshx) + SSHX_SECRET_BACKEND Secret store: keyring (default) or local-vault + SSHX_VAULT_PASSPHRASE Unlock passphrase for local-vault (unattended) + SSHX_VAULT_KEY_FILE 0600 file containing the vault passphrase (wins over env) +` + +const usageSSHExamples = ` +SSH Examples: + # Execute simple command (default user: master) + sshx -h=192.168.1.100 "uptime" + + # Execute sudo command (auto password from keyring: master) + sshx -h=192.168.1.100 "sudo systemctl status docker" + + # Use custom sudo password key for specific server + sshx -h=192.168.1.100 -pk=server-A "sudo systemctl restart nginx" + sshx -h=192.168.1.101 -pk=server-B "sudo systemctl restart nginx" + + # Custom SSH port + sshx -h=192.168.1.100 -p=2222 "ps aux | grep nginx" + + # Bind the local source address (IP or interface name) + sshx -h=prod-web --bind=en0 "uptime" + + # Structured JSON output for scripts/agents (one object on stdout) + sshx -h=192.168.1.100 --json "systemctl is-active nginx" + + # Preview the execution plan without connecting or reading secrets + sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx" + + # Save audit events for this project + sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx" + + # Bound the command wait (does not guarantee remote termination) + sshx -h=192.168.1.100 --timeout=30s "apt-get update" + + # Dangerous command will be blocked + sshx -h=192.168.1.100 "sudo rm -rf /tmp/*" # Safe + sshx -h=192.168.1.100 "sudo rm -rf /" # ⚠️ BLOCKED! + + # Force execute (bypass safety check - use with caution!) + sshx -h=192.168.1.100 --force "sudo reboot" + sshx -h=192.168.1.100 -f "sudo systemctl reboot" +` + +const usageInspectExamples = ` +Inspection Examples: + # Inspect stable system/network state in one invocation + sshx inspect -h=prod-web system.baseline --json + + # Create a locally editable Docker capability, validate it, then trust it + sshx plugin create docker.environment --template=docker --privilege=optional --json + sshx plugin validate docker.environment --json + sshx plugin test docker.environment --fixture=complete --json + sshx plugin trust docker.environment --json + + # Inspect once and persist only the redacted observation on the target + sshx inspect -h=prod-web docker.environment --cache=remote-prefer --json +` + +const usageSkillExample = ` +Agent Skill Example: + # Install after Homebrew/go install, or refresh after upgrading sshx + sshx skill install + + # Replace a locally modified copy after reviewing the difference + sshx skill install --force --json +` + +const usageSFTPExamples = ` +SFTP Examples: + # Upload file + sshx -h=192.168.1.100 --upload=local.txt --to=/tmp/remote.txt + + # Download file + sshx -h=192.168.1.100 --download=/var/log/app.log --to=./app.log + + # List directory + sshx -h=192.168.1.100 --list=/var/log + + # Create directory + sshx -h=192.168.1.100 --mkdir=/tmp/newdir + + # Remove file + sshx -h=192.168.1.100 --rm=/tmp/oldfile.txt + + # Batch upload + for file in *.txt; do + sshx -h=192.168.1.100 --upload=$file --to=/backup/$file + done +` + +const usageTransferExamples = ` +Server-to-Server Transfer Examples: + # Transfer a file directly between two servers (by IP) + sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log + + # Transfer between configured hosts (from settings.json) + sshx --transfer=prod-web:/etc/nginx/nginx.conf --to=staging-web:/etc/nginx/nginx.conf + + # Transfer a whole directory recursively + sshx --transfer=prod-db:/var/backups --to=backup-server:/mnt/archive/db + + # If the destination is an existing directory, the source is placed inside it + sshx --transfer=prod-web:/var/log/app.log --to=log-server:/var/logs/ + + # Preview the transfer plan without connecting + sshx --transfer=prod-web:/data --to=prod-db:/data --dry-run +` + +const usagePasswordExamples = ` +Password Management Examples: + # Set default sudo password (interactive prompt) + sshx --password-set=master + + # Set sudo password (inline, not recommended for security) + sshx --password-set=master:mypassword + + # Set passwords for different servers with same username + sshx --password-set=server-A + sshx --password-set=server-B + sshx --password-set=server-C + + # Use different password keys for different servers + sshx -h=192.168.1.100 -pk=server-A "sudo systemctl status nginx" + sshx -h=192.168.1.101 -pk=server-B "sudo systemctl status nginx" + sshx -h=192.168.1.102 -pk=server-C "sudo systemctl status nginx" + + # Set password for specific user + sshx --password-set=root + sshx --password-set=admin + + # Get password from OS keyring (refused when using local-vault) + sshx --password-get=master + + # Headless server: encrypted local vault (write-only; Agent never sees the value) + SSHX_SECRET_BACKEND=local-vault SSHX_VAULT_PASSPHRASE='…' \ + sshx --password-set=prod-web + SSHX_SECRET_BACKEND=local-vault SSHX_VAULT_PASSPHRASE='…' \ + sshx --password-check=prod-web + + + # Check if password exists + sshx --password-check=server-A + + # List common password keys + sshx --password-list + + # Delete password from keyring + sshx --password-delete=server-A +` + +const usageHostExamples = ` +Host Management Examples: + # Add host interactively + sshx --host-add + + # Add host with command line options + sshx --host-add --host-name=prod-web -h=192.168.1.100 -u=root -pk=prod-web --host-desc="Production Web Server" + + # Add host with its own SSH private key + sshx --host-add --host-name=prod-db -h=192.168.1.200 -u=admin -i=~/.ssh/prod-db.pem + + # Persist a source bind for a named host + sshx --host-add --host-name=edge --bind=en0 -h=100.117.253.247 -p=18922 + + # Update host IP address + sshx --host-update --host-name=prod-web -h=192.168.1.101 + + # Update host SSH key + sshx --host-update --host-name=prod-web -i=~/.ssh/new-key.pem + + # Update host password key + sshx --host-update --host-name=prod-web -pk=new-password-key + + # Update multiple fields + sshx --host-update --host-name=prod-web -h=192.168.1.101 -u=admin -pk=new-key + + # List all configured hosts + sshx --host-list + + # Test connection to a configured host + sshx --host-test=prod-web + + # Test all configured hosts and get a report with auth methods + sshx --host-test-all + + # Remove a host from configuration + sshx --host-remove=prod-web + + # Use configured host (looks up from settings if not an IP) + sshx -h=prod-web "uptime" +` + +const usageNotes = ` +Note: + - SSH key authentication is tried first; password auth is used only when SSH_PASSWORD is provided + - Sudo password is auto-filled only when the remote command starts with sudo + - Dry-run never connects, executes, reads keyring secrets, or writes state + - Audit events are JSONL files under ~/.sshx/audit by default + - SFTP operations use the same SSH connection + - Password manager works across macOS/Linux/Windows + - Default user: master, Default sudo key: master + - Host configurations are stored in ~/.sshx/settings.json` + +const usageAuditQuery = ` +Audit Query and Export: + sshx audit query [filters] [--json] + sshx audit export --to= [filters] + + Read-only consumption of the local JSONL trail. Query never connects and never + writes; export writes only the requested file. + + --since=YYYY-MM-DD Inclusive lower bound (date or RFC3339) + --until=YYYY-MM-DD Exclusive upper bound + --target=NAME Filter by target host name + --action=ACTION Filter by action (command, run, apply, sql, ...) + --run-id=ID Filter by run id + --execution-id=ID Filter by execution id + --error-kind=KIND Filter by error kind + --bypass-only Only events that used a trust/safety bypass + --to=FILE Export destination (audit export only) + --json Emit the sshx.audit.v1 query document on stdout + + Empty results exit 0. Corrupt or partial records are reported as diagnostics + and the valid records are retained. + + sshx audit query --since=2026-09-01 --target=prod-web --json + sshx audit query --execution-id= --json + sshx audit export --to=./incident.jsonl --since=2026-09-01 +` + +const usageMCP = ` +MCP Server (stdio): + sshx mcp + + Serves the execution contract to an MCP client over stdio. The server is + spawned and owned by the client, lives for exactly one client session, and + re-enters sshx as one-shot child processes per tool call: no listening socket, + no resident state, no connection pool. Tools map 1:1 to CLI verbs and return + the same versioned JSON, and password management is never exposed as a tool. +` diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index d3412ac..5d878d6 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -1,8 +1,13 @@ package app import ( + "encoding/json" "strings" "testing" + + "github.com/stretchr/testify/require" + + "github.com/talkincode/sshx/internal/sshclient" ) func TestPrintUsage(t *testing.T) { @@ -162,3 +167,70 @@ func TestPrintUsage_Examples(t *testing.T) { } } } + +// Every subcommand renders its own usage document, and sshx.help.v1 carries the +// same blocks in machine-readable form. The global help points at both. +func TestPrintVerbUsage(t *testing.T) { + // sshx text keeps its own Agent-facing document rather than the generic + // section rendering, in both the human and the JSON variant. + t.Run("text", func(t *testing.T) { + output := string(captureStdout(t, func() { + require.NoError(t, PrintVerbUsage(&sshclient.Config{HelpVerb: "text"})) + })) + require.Contains(t, output, "bounded remote text and log dissection") + require.Contains(t, output, "sshx.text.v1") + }) + for _, verb := range helpVerbs { + if verb == "text" { + continue + } + t.Run(verb, func(t *testing.T) { + var printErr error + output := string(captureStdout(t, func() { + printErr = PrintVerbUsage(&sshclient.Config{HelpVerb: verb}) + })) + require.NoError(t, printErr) + require.Contains(t, output, "sshx "+verb+" — ") + require.Contains(t, output, "SSH Options:") + require.Contains(t, output, "sshx "+verb+" --help --json") + for _, section := range usageSections() { + if section.verb == verb { + require.Contains(t, output, section.title+":") + } + } + }) + } + + unknown := PrintVerbUsage(&sshclient.Config{HelpVerb: "no-such-verb"}) + require.Error(t, unknown) +} + +func TestPrintVerbUsageJSON(t *testing.T) { + for _, verb := range helpVerbs { + if verb == "text" { + continue // covered by TestTextHelpJSON in the compiled-binary E2E suite + } + t.Run(verb, func(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintVerbUsage(&sshclient.Config{HelpVerb: verb, JSONOutput: true})) + }) + var document verbHelpDocument + require.NoError(t, json.Unmarshal(output, &document)) + require.Equal(t, helpSchemaVersion, document.SchemaVersion) + require.Equal(t, verb, document.Verb) + require.NotEmpty(t, document.Summary) + require.NotEmpty(t, document.Usage) + for _, section := range document.Usage { + require.NotEmpty(t, section.Title) + require.NotEmpty(t, section.Text) + } + }) + } +} + +func TestPrintUsageAdvertisesHelpSurfaces(t *testing.T) { + output := string(captureStdout(t, PrintUsage)) + require.Contains(t, output, "sshx --help") + require.Contains(t, output, "--quiet, --no-notices") + require.Contains(t, output, "should pass --quiet") +} diff --git a/internal/execution/executor.go b/internal/execution/executor.go index b97c67e..f43cdca 100644 --- a/internal/execution/executor.go +++ b/internal/execution/executor.go @@ -678,6 +678,16 @@ func executeOneWithMetadata(ctx context.Context, opts RunOptions, target Resolve return res } +// SudoKeyForTarget resolves the sudo keyring reference for one run target: the +// caller's explicit key wins, then the host's configured sudo_password_key, then +// the built-in default. The caller's key is empty unless it was chosen +// explicitly (Policy.SudoPasswordKey), so the default key can never shadow a +// host's own sudo key (issue #78), and the plan, the SSH client, and the keyring +// lookup all resolve the same reference. +func SudoKeyForTarget(policyKey, targetKey string) string { + return firstNonEmpty(policyKey, targetKey, sshclient.DefaultSudoKey) +} + func applyExecResult(res *TargetResult, req *Request, execRes sshclient.ExecResult, execErr error) { res.Stdout = execRes.Stdout res.Stderr = execRes.Stderr @@ -751,7 +761,7 @@ func buildSSHConfig(req *Request, target ResolvedTarget) *sshclient.Config { AllowInsecureHostKey: req.Policy.AllowInsecureHostKey, KnownHostsPath: req.Policy.KnownHostsPath, JSONOutput: true, - SudoKey: firstNonEmpty(target.SudoPasswordKey, req.Policy.SudoPasswordKey), + SudoKey: SudoKeyForTarget(req.Policy.SudoPasswordKey, target.SudoPasswordKey), Command: req.Action.Command, Mode: "ssh", Bind: target.Bind, @@ -853,10 +863,7 @@ func applySecrets(cfg *sshclient.Config, req *Request, target ResolvedTarget, se if err := ctx.Err(); err != nil { return err } - sudoKey := firstNonEmpty(req.Policy.SudoPasswordKey, target.SudoPasswordKey) - if sudoKey == "" { - sudoKey = sshclient.DefaultSudoKey - } + sudoKey := SudoKeyForTarget(req.Policy.SudoPasswordKey, target.SudoPasswordKey) cfg.SudoKey = sudoKey if secrets == nil { return fmt.Errorf("%w: sudo password key %q requested without secret resolver", ErrConfig, sudoKey) diff --git a/internal/execution/selector_test.go b/internal/execution/selector_test.go index fcc5a61..7c2a187 100644 --- a/internal/execution/selector_test.go +++ b/internal/execution/selector_test.go @@ -3,6 +3,8 @@ package execution import ( "errors" "testing" + + "github.com/talkincode/sshx/internal/sshclient" ) func sampleHosts() []HostRecord { @@ -137,3 +139,28 @@ func TestResolveTargets_BindInheritOverrideAndClear(t *testing.T) { t.Fatalf("literal target bind = %#v", snap.Targets[0]) } } + +// The sudo keyring reference is resolved once for the plan, the SSH client, and +// the keyring lookup: an explicit caller key wins, a host's configured +// sudo_password_key comes next, and the built-in default is the last resort +// (issue #78). +func TestSudoKeyForTargetPrecedence(t *testing.T) { + tests := []struct { + name string + policyKey string + targetKey string + want string + }{ + {"explicit caller key wins", "explicit-sudo", "host-sudo", "explicit-sudo"}, + {"host key beats the default", "", "host-sudo", "host-sudo"}, + {"host key fills a target without one", "", "", sshclient.DefaultSudoKey}, + {"explicit caller key fills a target without one", "explicit-sudo", "", "explicit-sudo"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := SudoKeyForTarget(test.policyKey, test.targetKey); got != test.want { + t.Fatalf("SudoKeyForTarget(%q, %q) = %q, want %q", test.policyKey, test.targetKey, got, test.want) + } + }) + } +} diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 0000000..c3daa80 --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,225 @@ +package plugin + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + // MaxInstallBytes bounds the total payload copied from an install source. + MaxInstallBytes = 8 << 20 + // MaxInstallFiles bounds the number of entries copied from an install source. + MaxInstallFiles = 128 +) + +// InstallOptions configures `sshx plugin install`. +type InstallOptions struct { + Source string // local directory that holds the plugin + Replace bool // preserve an existing plugin as a backup (--replace) + Trust bool // trust the installed digest in the same step (--trust) +} + +// InstallResult reports the published plugin. +type InstallResult struct { + Resolved *Resolved + Files []string + BackupPath string +} + +// Install publishes a local plugin directory into the runtime plugin root +// ($SSHX_HOME/plugins/). The source is staged with sshx's own restrictive +// modes, validated through the same loader the executor uses, and only then +// published, so an invalid source can never replace an installed plugin. --trust +// records the published digest in the local trust lock in the same step. +// +// This is the audited alternative to hand-placing files under the plugin root: +// it is one CLI invocation, it refuses symlinks and non-regular files, and it is +// bounded by MaxInstallBytes/MaxInstallFiles. +func Install(options InstallOptions) (*InstallResult, error) { + source, sourceErr := filepath.Abs(strings.TrimSpace(options.Source)) + if sourceErr != nil { + return nil, fmt.Errorf("resolve install source: %w", sourceErr) + } + // The source itself must be a real directory: a symlinked source would make + // "install this directory" mean "install whatever it points at". + if linkInfo, linkErr := os.Lstat(source); linkErr != nil { + if os.IsNotExist(linkErr) { + return nil, fmt.Errorf("install source %s does not exist", source) + } + return nil, fmt.Errorf("inspect install source: %w", linkErr) + } else if !linkInfo.IsDir() || linkInfo.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return nil, fmt.Errorf("install source must be a real directory (not a symlink): %s", source) + } + + // A rooted handle keeps every source read inside the source directory even if + // the tree changes while it is being copied. + sourceRoot, rootErr := os.OpenRoot(source) + if rootErr != nil { + if os.IsNotExist(rootErr) { + return nil, fmt.Errorf("install source %s does not exist", source) + } + return nil, fmt.Errorf("inspect install source: %w", rootErr) + } + defer func() { _ = sourceRoot.Close() }() //nolint:errcheck // read-only handle cleanup + if info, statErr := sourceRoot.Stat("."); statErr != nil { + return nil, fmt.Errorf("inspect install source: %w", statErr) + } else if !info.IsDir() { + return nil, fmt.Errorf("install source must be a real directory (not a symlink): %s", source) + } + + // The source is caller-owned staging: its permissions are not a plugin + // contract, because the staged copy below is written with sshx's own modes + // and then validated. Only the manifest identity is read from the source. + manifestInfo, manifestStatErr := sourceRoot.Lstat(ManifestFile) + if manifestStatErr != nil { + return nil, fmt.Errorf("read manifest: %w", manifestStatErr) + } + if !manifestInfo.Mode().IsRegular() || manifestInfo.Size() > MaxManifest { + return nil, fmt.Errorf("read manifest: %s is not a regular file under %d bytes", ManifestFile, MaxManifest) + } + manifestBytes, readErr := sourceRoot.ReadFile(ManifestFile) + if readErr != nil { + return nil, fmt.Errorf("read manifest: %w", readErr) + } + var manifest Manifest + if decodeErr := decodeStrictJSON(manifestBytes, &manifest); decodeErr != nil { + return nil, fmt.Errorf("parse manifest: %w", decodeErr) + } + id := strings.TrimSpace(manifest.ID) + if idErr := ValidateID(id); idErr != nil { + return nil, idErr + } + if _, builtin := resolveBuiltin(id); builtin { + return nil, fmt.Errorf("plugin id %q is reserved by a built-in capability", id) + } + + root, rootErr := Root() + if rootErr != nil { + return nil, rootErr + } + if rootErr := ensurePrivateRoot(root); rootErr != nil { + return nil, rootErr + } + + tempDir, tempErr := os.MkdirTemp(root, ".install-"+id+"-*") + if tempErr != nil { + return nil, fmt.Errorf("stage plugin install: %w", tempErr) + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(tempDir) //nolint:errcheck // best-effort cleanup inside the constrained plugin root + } + }() + // os.MkdirTemp already created the staging directory owner-only (0700). + + files, copyErr := copyPluginTree(sourceRoot, tempDir, manifest) + if copyErr != nil { + return nil, copyErr + } + if _, validateErr := loadFromPath(tempDir, id); validateErr != nil { + return nil, fmt.Errorf("validate source plugin: %w", validateErr) + } + + target := filepath.Join(root, id) + var backupPath string + if _, lstatErr := os.Lstat(target); lstatErr == nil { + if !options.Replace { + return nil, fmt.Errorf("plugin %q already exists; use --replace to preserve it as a backup", id) + } + var backupErr error + backupPath, backupErr = backupExisting(target, id) + if backupErr != nil { + return nil, backupErr + } + } else if !os.IsNotExist(lstatErr) { + return nil, fmt.Errorf("inspect existing plugin: %w", lstatErr) + } + published, renameErr := publishStagedDir(tempDir, target, backupPath) + if renameErr != nil { + return nil, renameErr + } + backupPath = published + cleanup = false + + resolved, resolveErr := Resolve(id) + if resolveErr != nil { + return nil, fmt.Errorf("validate installed plugin: %w", resolveErr) + } + if options.Trust { + resolved, resolveErr = trustResolved(resolved) + if resolveErr != nil { + return nil, resolveErr + } + } + return &InstallResult{Resolved: resolved, Files: files, BackupPath: backupPath}, nil +} + +// copyPluginTree stages one source plugin under the plugin root with sshx's own +// modes: directories 0700, files 0600, and the declared entrypoint 0700. Symlinks +// and non-regular files are refused so a source cannot pull content from outside +// the plugin directory, and both handles are rooted, so the copy stays inside the +// source and the staging directory even if the trees change mid-walk. +func copyPluginTree(sourceRoot *os.Root, tempDir string, manifest Manifest) ([]string, error) { + stageRoot, rootErr := os.OpenRoot(tempDir) + if rootErr != nil { + return nil, fmt.Errorf("stage plugin install: %w", rootErr) + } + defer func() { _ = stageRoot.Close() }() //nolint:errcheck // staging handle cleanup + + entrypoint := filepath.ToSlash(filepath.Clean(manifest.Runner.Entrypoint)) + files := make([]string, 0, 8) + var total int64 + walkErr := fs.WalkDir(sourceRoot.FS(), ".", func(relative string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if relative == "." { + return nil + } + switch { + case entry.Type()&fs.ModeSymlink != 0: + return fmt.Errorf("install source contains a symlink (%s); plugins hold plain files only", relative) + case entry.IsDir(): + return stageRoot.Mkdir(relative, 0o700) + case !entry.Type().IsRegular(): + return fmt.Errorf("install source contains a non-regular entry (%s)", relative) + } + if len(files) >= MaxInstallFiles { + return fmt.Errorf("install source holds more than %d files", MaxInstallFiles) + } + info, infoErr := entry.Info() + if infoErr != nil { + return fmt.Errorf("read %s: %w", relative, infoErr) + } + if info.Size() > MaxInstallBytes { + return fmt.Errorf("read %s: file exceeds %d-byte limit", relative, MaxInstallBytes) + } + data, readErr := sourceRoot.ReadFile(relative) + if readErr != nil { + return fmt.Errorf("read %s: %w", relative, readErr) + } + total += int64(len(data)) + if total > MaxInstallBytes { + return fmt.Errorf("install source exceeds %d bytes", MaxInstallBytes) + } + mode := os.FileMode(0o600) + if relative == entrypoint { + mode = 0o700 + } + if writeErr := stageRoot.WriteFile(relative, data, mode); writeErr != nil { + return fmt.Errorf("stage %s: %w", relative, writeErr) + } + files = append(files, relative) + return nil + }) + if walkErr != nil { + return nil, walkErr + } + sort.Strings(files) + return files, nil +} diff --git a/internal/plugin/install_test.go b/internal/plugin/install_test.go new file mode 100644 index 0000000..6173b2c --- /dev/null +++ b/internal/plugin/install_test.go @@ -0,0 +1,310 @@ +package plugin + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// installSourcePath stages one scaffolded plugin as an external source directory +// with caller-owned permissions: install sanitizes modes instead of inheriting +// them, so the source deliberately keeps a permissive layout. The scaffold is +// built in a scratch runtime root, then copied out, because a source is by +// definition not part of the plugin root. +func installSourcePath(t *testing.T, id string) string { + t.Helper() + runtimeRoot := os.Getenv("SSHX_HOME") + scratch := t.TempDir() + t.Setenv("SSHX_HOME", scratch) + created, err := Create(CreateOptions{ID: id, Template: "generic"}) + if err != nil { + t.Fatalf("Create(%q) error = %v", id, err) + } + t.Setenv("SSHX_HOME", runtimeRoot) + source := filepath.Join(t.TempDir(), id) + if copyErr := copyDirForTest(created.Resolved.Path, source); copyErr != nil { + t.Fatalf("stage source: %v", copyErr) + } + return source +} + +func copyDirForTest(from, to string) error { + if err := os.MkdirAll(to, 0o750); err != nil { // #nosec G301 -- fixture stages a caller-owned source layout. + return err + } + sourceRoot, err := os.OpenRoot(from) + if err != nil { + return err + } + defer func() { _ = sourceRoot.Close() }() //nolint:errcheck // fixture handle cleanup + stageRoot, err := os.OpenRoot(to) + if err != nil { + return err + } + defer func() { _ = stageRoot.Close() }() //nolint:errcheck // fixture handle cleanup + return fs.WalkDir(sourceRoot.FS(), ".", func(relative string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if relative == "." { + return nil + } + if entry.IsDir() { + return stageRoot.Mkdir(relative, 0o750) + } + data, readErr := sourceRoot.ReadFile(relative) + if readErr != nil { + return readErr + } + return stageRoot.WriteFile(relative, data, 0o600) // #nosec G306 -- fixture copy of its own scaffold. + }) +} + +func TestInstallPublishesValidatedPluginWithRestrictiveModes(t *testing.T) { + t.Setenv("SSHX_HOME", t.TempDir()) + source := installSourcePath(t, "installed.inspect") + + installed, err := Install(InstallOptions{Source: source}) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if installed.Resolved.Trusted { + t.Fatal("installed plugin must stay untrusted until it is explicitly trusted") + } + if installed.BackupPath != "" { + t.Fatalf("fresh install must not back anything up, got %q", installed.BackupPath) + } + if installed.Resolved.Manifest.ID != "installed.inspect" { + t.Fatalf("installed id = %q", installed.Resolved.Manifest.ID) + } + root, err := Root() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "installed.inspect"); installed.Resolved.Path != want { + t.Fatalf("installed path = %q, want %q", installed.Resolved.Path, want) + } + for _, relative := range installed.Files { + info, statErr := os.Stat(filepath.Join(installed.Resolved.Path, relative)) + if statErr != nil { + t.Fatalf("stat %s: %v", relative, statErr) + } + wantMode := os.FileMode(0o600) + if relative == "collectors/linux.sh" { + wantMode = 0o700 + } + if runtime.GOOS == "windows" { + continue // synthetic mode bits on Windows are not ACL evidence + } + if info.Mode().Perm() != wantMode { + t.Fatalf("%s mode = %04o, want %04o", relative, info.Mode().Perm(), wantMode) + } + } + // The published plugin is immediately usable through the normal loader. + if _, resolveErr := Resolve("installed.inspect"); resolveErr != nil { + t.Fatalf("Resolve(installed) error = %v", resolveErr) + } +} + +func TestInstallReplacesWithBackupAndTrustsInOneStep(t *testing.T) { + t.Setenv("SSHX_HOME", t.TempDir()) + source := installSourcePath(t, "installed.inspect") + + if _, err := Install(InstallOptions{Source: source}); err != nil { + t.Fatal(err) + } + if _, err := Install(InstallOptions{Source: source}); err == nil { + t.Fatal("second install succeeded without --replace") + } + replaced, err := Install(InstallOptions{Source: source, Replace: true, Trust: true}) + if err != nil { + t.Fatalf("Install(replace, trust) error = %v", err) + } + if replaced.BackupPath == "" { + t.Fatal("replace must preserve the previous plugin as a backup") + } + if _, statErr := os.Stat(filepath.Join(replaced.BackupPath, ManifestFile)); statErr != nil { + t.Fatalf("backup missing manifest: %v", statErr) + } + if !replaced.Resolved.Trusted { + t.Fatal("--trust must record the published digest in the same step") + } + // The trust lock stores the published digest, not the source's bytes. + listed, err := List() + if err != nil { + t.Fatal(err) + } + for _, summary := range listed { + if summary.ID != "installed.inspect" { + continue + } + if !summary.Trusted || summary.Digest != replaced.Resolved.Digest { + t.Fatalf("listing = %#v", summary) + } + } +} + +func TestInstallRefusesSymlinksNonDirectoriesAndReservedIDs(t *testing.T) { + t.Setenv("SSHX_HOME", t.TempDir()) + source := installSourcePath(t, "installed.inspect") + if symlinkErr := os.Symlink(filepath.Join(source, ManifestFile), filepath.Join(source, "linked.json")); symlinkErr != nil { + t.Skipf("symlinks unsupported here: %v", symlinkErr) + } + if _, err := Install(InstallOptions{Source: source}); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("symlink source error = %v", err) + } + if _, err := Install(InstallOptions{Source: filepath.Join(source, ManifestFile)}); err == nil { + t.Fatal("installing a file instead of a directory must fail") + } + if _, err := Install(InstallOptions{Source: filepath.Join(t.TempDir(), "missing")}); err == nil { + t.Fatal("missing source must fail") + } + reserved := installSourcePath(t, "installed.inspect") + manifestPath := filepath.Join(reserved, ManifestFile) + data, readErr := os.ReadFile(manifestPath) // #nosec G304 -- fixture reads its own staged manifest. + if readErr != nil { + t.Fatal(readErr) + } + reservedManifest := strings.Replace(string(data), `"installed.inspect"`, `"network.dns"`, 1) + if writeErr := os.WriteFile(manifestPath, []byte(reservedManifest), 0o600); writeErr != nil { // #nosec G304,G703 -- fixture rewrites its own staged manifest. + t.Fatal(writeErr) + } + if _, err := Install(InstallOptions{Source: reserved}); err == nil || !strings.Contains(err.Error(), "built-in") { + t.Fatalf("built-in id error = %v", err) + } +} + +// A source that cannot be validated must never reach the published plugin: the +// validation happens on a staged copy before the installed directory is touched. +func TestInstallKeepsPublishedPluginWhenSourceIsInvalid(t *testing.T) { + t.Setenv("SSHX_HOME", t.TempDir()) + source := installSourcePath(t, "installed.inspect") + published, err := Install(InstallOptions{Source: source}) + if err != nil { + t.Fatal(err) + } + broken := installSourcePath(t, "installed.inspect") + collector := filepath.Join(broken, "collectors", "linux.sh") + if removeErr := os.Remove(collector); removeErr != nil { + t.Fatal(removeErr) + } + if _, err := Install(InstallOptions{Source: broken, Replace: true}); err == nil || !strings.Contains(err.Error(), "validate source plugin") { + t.Fatalf("invalid source error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(published.Resolved.Path, "collectors", "linux.sh")); statErr != nil { + t.Fatalf("published plugin was disturbed by a failed install: %v", statErr) + } + if _, resolveErr := Resolve("installed.inspect"); resolveErr != nil { + t.Fatalf("published plugin no longer resolves: %v", resolveErr) + } +} + +// A missing plugin names the directory that was searched, so a caller does not +// have to derive the plugin root from the source or the documentation. +func TestResolveMissingPluginNamesTheSearchedDirectory(t *testing.T) { + root := t.TempDir() + t.Setenv("SSHX_HOME", root) + _, err := Resolve("missing.plugin") + if err == nil { + t.Fatal("missing plugin resolved") + } + if !strings.Contains(err.Error(), filepath.Join(root, "plugins")) { + t.Fatalf("error does not name the plugin root: %v", err) + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("error does not report a miss: %v", err) + } +} + +// A killed install can leave a staging directory behind. It is not a plugin, and +// a dot-prefixed name can never be a valid plugin id, so the inventory must not +// report it as an INVALID entry that hides the real local plugins (issue #88). +func TestListSkipsInterruptedInstallStaging(t *testing.T) { + root := t.TempDir() + t.Setenv("SSHX_HOME", root) + source := installSourcePath(t, "installed.inspect") + published, err := Install(InstallOptions{Source: source}) + if err != nil { + t.Fatal(err) + } + pluginsRoot, err := Root() + if err != nil { + t.Fatal(err) + } + leftover := filepath.Join(pluginsRoot, ".install-installed.inspect-123456") + if mkdirErr := os.MkdirAll(leftover, 0o700); mkdirErr != nil { + t.Fatal(mkdirErr) + } + listed, err := List() + if err != nil { + t.Fatal(err) + } + for _, summary := range listed { + if strings.HasPrefix(summary.ID, ".install-") { + t.Fatalf("staging directory reported as a plugin: %#v", summary) + } + } + found := false + for _, summary := range listed { + if summary.ID == "installed.inspect" { + found = true + if !summary.Valid || summary.Path != published.Resolved.Path { + t.Fatalf("published plugin listing = %#v", summary) + } + } + } + if !found { + t.Fatalf("published plugin missing from %#v", listed) + } +} + +// A failed publication must not leave the plugin missing: the previous plugin is +// restored when the staged move fails and the restore can run, and it is kept at +// its backup path when the restore cannot (AGENT.md §9 recovery evidence). +func TestPublishStagedDirRestoresOrKeepsThePreviousPlugin(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + backup := filepath.Join(dir, "backup") + writeBackup := func(t *testing.T) { + t.Helper() + require.NoError(t, os.MkdirAll(backup, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(backup, ManifestFile), []byte("previous\n"), 0o600)) + } + + // The staged move fails (the staging directory was lost) while the previous + // plugin is still recoverable: it must come back instead of staying missing. + writeBackup(t) + kept, publishErr := publishStagedDir(filepath.Join(dir, "lost-stage"), target, backup) + require.Error(t, publishErr) + require.Contains(t, publishErr.Error(), "previous plugin restored") + require.Empty(t, kept, "a restored plugin no longer needs its backup path") + restored, readErr := os.ReadFile(filepath.Join(target, ManifestFile)) // #nosec G304 -- fixture path inside the owned test directory. + require.NoError(t, readErr, "the previous plugin must be back in place") + require.Equal(t, []byte("previous\n"), restored) + require.NoFileExists(t, backup, "the restore consumes the backup") + + // The rename fails because the target cannot be replaced and the restore + // cannot run either: the failure must name where the previous plugin is kept, + // and that recovery copy must survive. + writeBackup(t) + stage := filepath.Join(dir, "stage") + require.NoError(t, os.MkdirAll(stage, 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(target, "child"), 0o700)) + kept, publishErr = publishStagedDir(stage, target, backup) + require.Error(t, publishErr) + require.Contains(t, publishErr.Error(), "previous plugin kept at "+backup) + require.Equal(t, backup, kept) + _, statErr := os.Stat(filepath.Join(backup, ManifestFile)) + require.NoError(t, statErr, "the recovery copy must survive a failed publication") + + // Without a backup there is nothing to restore, and the error stays plain. + kept, publishErr = publishStagedDir(stage, target, "") + require.Error(t, publishErr) + require.NotContains(t, publishErr.Error(), "kept at") + require.Empty(t, kept) +} diff --git a/internal/plugin/store.go b/internal/plugin/store.go index 5d8c721..f00b977 100644 --- a/internal/plugin/store.go +++ b/internal/plugin/store.go @@ -46,6 +46,19 @@ func Path(id string) (string, error) { return filepath.Join(root, id), nil } +// ensurePrivateRoot creates the plugin root when it is missing and keeps it +// owner-only: a group- or world-writable plugin root would let another local +// account plant a plugin that later gets trusted. +func ensurePrivateRoot(root string) error { + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create plugin root: %w", err) + } + if err := os.Chmod(root, 0o700); err != nil { // #nosec G302 -- private directory requires owner traversal. + return fmt.Errorf("secure plugin root: %w", err) + } + return nil +} + func Resolve(id string) (*Resolved, error) { if builtin, ok := resolveBuiltin(id); ok { return builtin, nil @@ -62,7 +75,7 @@ func loadFromPath(pluginPath, expectedID string) (*Resolved, error) { rootInfo, rootErr := os.Lstat(pluginRoot) if rootErr != nil { if os.IsNotExist(rootErr) { - return nil, fmt.Errorf("plugin %q not found", expectedID) + return nil, fmt.Errorf("plugin %q not found in %s", expectedID, pluginRoot) } return nil, fmt.Errorf("inspect plugin root: %w", rootErr) } @@ -72,7 +85,7 @@ func loadFromPath(pluginPath, expectedID string) (*Resolved, error) { info, statErr := os.Lstat(pluginPath) if statErr != nil { if os.IsNotExist(statErr) { - return nil, fmt.Errorf("plugin %q not found", expectedID) + return nil, fmt.Errorf("plugin %q not found in %s", expectedID, pluginRoot) } return nil, fmt.Errorf("inspect plugin path: %w", statErr) } @@ -373,6 +386,13 @@ func List() ([]Summary, error) { if !entry.IsDir() { continue } + // sshx's own staging and scaffold directories (".install-*", ".create-*") + // are not plugins, and a dot-prefixed name can never be a valid plugin id: + // reporting them as INVALID entries would hide the real inventory after an + // interrupted install (issue #88). + if strings.HasPrefix(entry.Name(), ".") { + continue + } id := entry.Name() resolved, resolveErr := Resolve(id) if resolveErr != nil { @@ -465,6 +485,14 @@ func Trust(id string) (*Resolved, error) { if resolved.Builtin { return resolved, nil } + return trustResolved(resolved) +} + +// trustResolved records one already-resolved plugin digest in the local trust +// lock. An explicitly trusted digest is the only way a local plugin becomes +// admissible for remote execution. +func trustResolved(resolved *Resolved) (*Resolved, error) { + id := resolved.Manifest.ID lock, err := loadLock() if err != nil { return nil, err diff --git a/internal/plugin/templates.go b/internal/plugin/templates.go index 6c2771e..a6a2e41 100644 --- a/internal/plugin/templates.go +++ b/internal/plugin/templates.go @@ -62,11 +62,8 @@ func Create(options CreateOptions) (*CreateResult, error) { if rootErr != nil { return nil, rootErr } - if mkdirErr := os.MkdirAll(root, 0o700); mkdirErr != nil { - return nil, fmt.Errorf("create plugin root: %w", mkdirErr) - } - if chmodErr := os.Chmod(root, 0o700); chmodErr != nil { // #nosec G302 -- private directory requires owner traversal. - return nil, fmt.Errorf("secure plugin root: %w", chmodErr) + if rootErr := ensurePrivateRoot(root); rootErr != nil { + return nil, rootErr } target := filepath.Join(root, options.ID) var backupPath string @@ -93,9 +90,7 @@ func Create(options CreateOptions) (*CreateResult, error) { _ = os.RemoveAll(tempDir) //nolint:errcheck // best-effort cleanup inside constrained plugin root } }() - if chmodErr := os.Chmod(tempDir, 0o700); chmodErr != nil { // #nosec G302 -- private directory requires owner traversal. - return nil, chmodErr - } + // os.MkdirTemp already created the scaffold directory owner-only (0700). manifest := templateManifest(options) manifestData, marshalErr := json.MarshalIndent(manifest, "", " ") @@ -127,9 +122,11 @@ func Create(options CreateOptions) (*CreateResult, error) { } written = append(written, relative) } - if renameErr := os.Rename(tempDir, target); renameErr != nil { - return nil, fmt.Errorf("install plugin: %w", renameErr) + published, renameErr := publishStagedDir(tempDir, target, backupPath) + if renameErr != nil { + return nil, renameErr } + backupPath = published cleanup = false sort.Strings(written) resolved, resolveErr := Resolve(options.ID) @@ -168,6 +165,23 @@ func Remove(id string) (string, error) { return backup, nil } +// publishStagedDir moves a staged plugin directory into place and restores the +// previous plugin when the move fails, so a failed publication never leaves the +// plugin missing while a recovery copy exists elsewhere. It returns the backup +// path that still holds the previous plugin ("" when the restore consumed it). +func publishStagedDir(tempDir, target, backupPath string) (string, error) { + if err := os.Rename(tempDir, target); err != nil { + if backupPath != "" { + if restoreErr := os.Rename(backupPath, target); restoreErr == nil { + return "", fmt.Errorf("publish plugin: %w (previous plugin restored)", err) + } + return backupPath, fmt.Errorf("publish plugin: %w (previous plugin kept at %s)", err, backupPath) + } + return "", fmt.Errorf("publish plugin: %w", err) + } + return backupPath, nil +} + func backupExisting(source, id string) (string, error) { root, err := runtimeRoot() if err != nil { diff --git a/internal/plugin/types.go b/internal/plugin/types.go index 74330c9..08ed7ed 100644 --- a/internal/plugin/types.go +++ b/internal/plugin/types.go @@ -145,6 +145,9 @@ type ActionResult struct { Action string `json:"action"` PluginID string `json:"plugin_id,omitempty"` Path string `json:"path,omitempty"` + Builtin bool `json:"builtin,omitempty"` + PluginRoot string `json:"plugin_root,omitempty"` + Source string `json:"source,omitempty"` BackupPath string `json:"backup_path,omitempty"` Digest string `json:"digest,omitempty"` Trusted bool `json:"trusted,omitempty"` diff --git a/internal/sshclient/apply.go b/internal/sshclient/apply.go index aef5a3d..762e474 100644 --- a/internal/sshclient/apply.go +++ b/internal/sshclient/apply.go @@ -774,11 +774,21 @@ if [ -n "$EXPECT" ] && [ "$FORCE" = "1" ]; then precondition_status=bypassed; fi report() { printf '{"status":"%%s","changed":%%s,"created":%%s,"before":"%%s","after":"%%s","backup":"%%s","mode":"%%s","payload":"%%s","executed":%%s,"change_state":"%%s","verified":%%s,"verification":"%%s","backup_verified":%%s,"uid":%%s,"gid":%%s,"cleanup_pending":[%%s],"error":"%%s","replace_method":"%%s","precondition_status":"%%s","precondition_sha256":"%%s"}\n' "$status" "$changed" "$created" "$before" "$after" "$backup" "$mode" "$PAYLOAD_SHA" "$executed" "$change_state" "$verified" "$verification" "$backup_verified" "$uid" "$gid" "$cleanup_pending" "$error" "$replace_method" "$precondition_status" "$precondition_sha256" } +# remove_owned deletes one sshx-owned artifact without trusting a PATH rm: a +# wrapper named rm earlier in PATH could keep a copy of the payload behind (for +# example one that moves files to the Trash). An absolute remover is tried +# first so such a wrapper is bypassed on hosts that have one; POSIX unlink and +# PATH rm remain as fallbacks, and success means the path is gone rather than +# merely claimed removed. remove_owned() { - if ! rm -f "$1"; then - cleanup_pending="$cleanup_pending${cleanup_pending:+,}\"$1\"" - return 1 - fi + if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi + for remover in /bin/rm /usr/bin/rm /usr/local/bin/rm; do + if [ -x "$remover" ] && "$remover" -f "$1" && [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi + done + if command -v unlink >/dev/null 2>&1 && unlink "$1" && [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi + if command -v rm >/dev/null 2>&1 && rm -f "$1" && [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi + cleanup_pending="$cleanup_pending${cleanup_pending:+,}\"$1\"" + return 1 } finish() { rc=$? diff --git a/internal/sshclient/apply_cleanup_test.go b/internal/sshclient/apply_cleanup_test.go new file mode 100644 index 0000000..3f5f028 --- /dev/null +++ b/internal/sshclient/apply_cleanup_test.go @@ -0,0 +1,228 @@ +package sshclient + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestApplyCleanupDoesNotDependOnPathRm reproduces the reported leak where a host +// resolves rm through a wrapper earlier in PATH that moves files to the Trash +// instead of unlinking them: every apply left a byte-identical copy of the +// applied payload in the target user's Trash while the privileged report still +// claimed a clean run, because remove_owned() deleted sshx-owned artifacts with +// a bare `rm -f`. +// +// buildApplySudoScript is the only apply script builder in this package. The SFTP +// apply path (applySFTPFile) removes its temporaries over SFTP rather than through +// a remote shell, so it has no PATH-dependent cleanup to cover here. +func TestApplyCleanupDoesNotDependOnPathRm(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the privileged remote script requires a POSIX shell") + } + requireAbsoluteRemover(t) + + t.Run("published", func(t *testing.T) { + fixture := newApplyCleanupFixture(t) + result := fixture.run(t, fixture.env()) + outcome, applyErr := parseApplyScriptReport(result) + requireApplyOutcome(t, "published", result, outcome, applyErr) + require.NoError(t, applyErr, "%s", result.Stderr) + require.True(t, outcome.Verified) + require.NoFileExists(t, fixture.staging, "the staging payload must be removed") + fixture.requireNoOwnedTemp(t) + fixture.requireNoTrashLeak(t) + require.NotEmpty(t, outcome.BackupPath) + require.FileExists(t, outcome.BackupPath, "a verified backup must be preserved") + }) + + t.Run("temp candidate", func(t *testing.T) { + fixture := newApplyCleanupFixture(t) + fixture.shadow(t, "chmod", "case \"$2\" in *.sshx.*) exit 9;; esac\nexec '"+realCommand(t, "chmod")+"' \"$@\"\n") + result := fixture.run(t, fixture.env()) + outcome, applyErr := parseApplyScriptReport(result) + requireApplyOutcome(t, "temp candidate", result, outcome, applyErr) + require.Error(t, applyErr, "%s", result.Stderr) + require.NotZero(t, result.ExitCode, "%s", result.Stderr) + require.NoFileExists(t, fixture.tempCandidate(), "the publication temp candidate must be removed") + require.NoFileExists(t, fixture.staging, "the staging payload must be removed") + fixture.requireNoOwnedTemp(t) + fixture.requireNoTrashLeak(t) + require.Empty(t, outcome.CleanupPending, "a successful cleanup must not be reported as pending") + require.NotEmpty(t, outcome.BackupPath) + require.FileExists(t, outcome.BackupPath, "a verified backup must be preserved") + }) + + t.Run("unverified backup", func(t *testing.T) { + fixture := newApplyCleanupFixture(t) + fixture.shadow(t, "chmod", "case \"$2\" in '"+fixture.backupDir+"'/*) exit 9;; esac\nexec '"+realCommand(t, "chmod")+"' \"$@\"\n") + result := fixture.run(t, fixture.env()) + outcome, applyErr := parseApplyScriptReport(result) + requireApplyOutcome(t, "unverified backup", result, outcome, applyErr) + require.Error(t, applyErr, "%s", result.Stderr) + require.NotZero(t, result.ExitCode, "%s", result.Stderr) + require.NotEmpty(t, outcome.BackupPath) + require.NoFileExists(t, outcome.BackupPath, "an unverified backup must be removed") + entries, listErr := os.ReadDir(fixture.backupDir) + require.NoError(t, listErr) + require.Empty(t, entries) + require.NoFileExists(t, fixture.staging, "the staging payload must be removed") + fixture.requireNoOwnedTemp(t) + fixture.requireNoTrashLeak(t) + require.Empty(t, outcome.CleanupPending, "a successful cleanup must not be reported as pending") + }) + + t.Run("unremovable artifact", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions do not prevent removal") + } + fixture := newApplyCleanupFixture(t) + stagingDir := filepath.Dir(fixture.staging) + require.NoError(t, os.Chmod(stagingDir, 0o500)) // #nosec G302 -- directory fixture, not a secret file. + t.Cleanup(func() { + _ = os.Chmod(stagingDir, 0o700) //nolint:errcheck,gosec // restore so t.TempDir cleanup can remove the tree. + }) + result := fixture.run(t, fixture.env()) + outcome, applyErr := parseApplyScriptReport(result) + requireApplyOutcome(t, "unremovable artifact", result, outcome, applyErr) + require.Equal(t, 4, result.ExitCode, "%s", result.Stderr) + require.ErrorContains(t, applyErr, "artifact cleanup failed") + require.Equal(t, []string{fixture.staging}, outcome.CleanupPending, + "an artifact that cannot be removed must be reported instead of hidden") + require.FileExists(t, fixture.staging, "a failed removal must leave the artifact in place") + // The payload itself was published and verified; the invocation still fails + // because an owned artifact leaked. + published, readErr := os.ReadFile(fixture.target) // #nosec G304 -- path is confined to the owned test fixture. + require.NoError(t, readErr) + require.Equal(t, fixture.payload, published) + require.True(t, outcome.Verified) + fixture.requireNoTrashLeak(t) + }) +} + +// applyCleanupFixture is an apply fixture whose PATH resolves rm to a wrapper that +// moves every argument into a fake Trash directory and never unlinks anything. +type applyCleanupFixture struct { + dir string + binDir string + trashDir string + staging string + target string + backupDir string + before []byte + payload []byte +} + +func newApplyCleanupFixture(t *testing.T) *applyCleanupFixture { + t.Helper() + fixture := &applyCleanupFixture{ + dir: t.TempDir(), + before: []byte("before\n"), + payload: []byte("after\n"), + } + fixture.binDir = filepath.Join(fixture.dir, "bin") + fixture.trashDir = filepath.Join(fixture.dir, "trash") + fixture.staging = filepath.Join(fixture.dir, "staging", "stage.new") + fixture.target = filepath.Join(fixture.dir, "app.conf") + fixture.backupDir = filepath.Join(fixture.dir, "backups") + for _, dir := range []string{fixture.binDir, fixture.trashDir, filepath.Dir(fixture.staging)} { + require.NoError(t, os.MkdirAll(dir, 0o700)) + } + require.NoError(t, os.WriteFile(fixture.target, fixture.before, 0o640)) // #nosec G306 -- fixture mirrors a group-readable config file. + require.NoError(t, os.WriteFile(fixture.staging, fixture.payload, 0o600)) + fixture.shadow(t, "rm", "mkdir -p '"+fixture.trashDir+"'\n"+ + "for argument in \"$@\"; do\n"+ + " case \"$argument\" in -*) continue ;; esac\n"+ + " if [ -e \"$argument\" ] || [ -L \"$argument\" ]; then mv \"$argument\" \""+fixture.trashDir+"/${argument##*/}.new\" || exit 1; fi\n"+ + "done\n"+ + "exit 0\n") + // The fixture only proves anything while the wrapper really is the rm the + // generated script resolves through PATH. + require.Equal(t, filepath.Join(fixture.binDir, "rm"), fixture.resolvedRm(t)) + return fixture +} + +// shadow installs an executable stand-in for one command ahead of PATH. +func (f *applyCleanupFixture) shadow(t *testing.T, name, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(f.binDir, name), []byte("#!/bin/sh\n"+body), 0o700)) // #nosec G306 -- owned executable fault fixture. +} + +// env is the environment the generated script runs with: the shadowing directory +// first, exactly like a host whose PATH prefers a wrapper over /bin/rm. +func (f *applyCleanupFixture) env() []string { + return append(os.Environ(), "PATH="+f.binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func (f *applyCleanupFixture) resolvedRm(t *testing.T) string { + t.Helper() + cmd := exec.Command("sh", "-c", "command -v rm") // #nosec G204 -- fixed command string, no caller input. + cmd.Env = applyScriptEnv(f.env()) + out, err := cmd.Output() + require.NoError(t, err) + return strings.TrimSpace(string(out)) +} + +func (f *applyCleanupFixture) run(t *testing.T, env []string) ExecResult { + t.Helper() + req := ApplyRequest{RemotePath: f.target, Payload: f.payload, Backup: true, ExpectSHA256: SHA256Hex(f.before)} + script, err := buildApplySudoScript(req, f.staging, f.backupDir) + require.NoError(t, err) + return runApplyScriptFixture(t, script, env) +} + +// tempCandidate mirrors the script's same-directory publication temp. +func (f *applyCleanupFixture) tempCandidate() string { + return filepath.Join(f.dir, "."+filepath.Base(f.target)+".sshx."+filepath.Base(f.staging)+".tmp") +} + +func (f *applyCleanupFixture) requireNoOwnedTemp(t *testing.T) { + t.Helper() + entries, err := os.ReadDir(f.dir) + require.NoError(t, err) + for _, entry := range entries { + require.NotContains(t, entry.Name(), ".sshx.", "owned temp must be cleaned") + } +} + +// requireNoTrashLeak is the regression assertion: cleanup must not resolve rm +// through PATH, so the wrapper must never be handed an owned artifact. +func (f *applyCleanupFixture) requireNoTrashLeak(t *testing.T) { + t.Helper() + entries, err := os.ReadDir(f.trashDir) + require.NoError(t, err) + leaked := make([]string, 0, len(entries)) + for _, entry := range entries { + leaked = append(leaked, entry.Name()) + } + require.Empty(t, leaked, "cleanup resolved rm through PATH and moved owned artifacts to the Trash: %v", leaked) +} + +// realCommand resolves a command without the fixture's shadowing directory. +func realCommand(t *testing.T, name string) string { + t.Helper() + resolved, err := exec.LookPath(name) + require.NoError(t, err) + return resolved +} + +// requireAbsoluteRemover skips when this host offers neither the absolute rm +// candidates nor unlink that the generated script relies on: without either, a +// PATH-independent cleanup cannot be exercised at all. +func requireAbsoluteRemover(t *testing.T) { + t.Helper() + for _, candidate := range []string{"/bin/rm", "/usr/bin/rm", "/usr/local/bin/rm"} { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return + } + } + if _, err := exec.LookPath("unlink"); err == nil { + return + } + t.Skip("no absolute rm and no unlink on this host: PATH-independent cleanup cannot be exercised") +} diff --git a/internal/sshclient/apply_sudo_test.go b/internal/sshclient/apply_sudo_test.go index b195e37..1d85a43 100644 --- a/internal/sshclient/apply_sudo_test.go +++ b/internal/sshclient/apply_sudo_test.go @@ -1,7 +1,6 @@ package sshclient import ( - "bytes" "encoding/json" "errors" "os" @@ -66,44 +65,46 @@ func TestApplySudoScriptEvidenceAndCleanup(t *testing.T) { } result := runApplyScriptFixture(t, script, env) outcome, applyErr := parseApplyScriptReport(result) - require.NotNil(t, outcome, "%s; %s; %v", result.Stdout, result.Stderr, applyErr) + requireApplyOutcome(t, test, result, outcome, applyErr) require.Equal(t, SHA256Hex(before), outcome.BeforeSHA256) require.Equal(t, SHA256Hex(payload), outcome.PayloadSHA256) - require.NotNil(t, outcome.UID) - require.NotNil(t, outcome.GID) + require.NotNil(t, outcome.UID, "%s: uid evidence missing: %s", test, result.Stdout) + require.NotNil(t, outcome.GID, "%s: gid evidence missing: %s", test, result.Stdout) require.Equal(t, "640", outcome.Mode) switch test { case "precondition": require.ErrorIs(t, applyErr, ErrPrecondition) - require.False(t, *outcome.Executed) + require.False(t, requireApplyExecuted(t, test, result, outcome, applyErr)) require.Empty(t, outcome.BackupPath) case "before": require.Error(t, applyErr) - require.False(t, *outcome.Executed) + require.False(t, requireApplyExecuted(t, test, result, outcome, applyErr)) require.Equal(t, "unchanged", outcome.ChangeState) require.True(t, outcome.BackupVerified) case "after": require.ErrorIs(t, applyErr, ErrApplyVerification) - require.True(t, *outcome.Executed) + require.True(t, requireApplyExecuted(t, test, result, outcome, applyErr)) require.Equal(t, "changed", outcome.ChangeState) require.Equal(t, SHA256Hex([]byte("other writer\n")), outcome.AfterSHA256) require.False(t, outcome.Verified) case "rename": require.ErrorIs(t, applyErr, ErrApplyVerification) - require.Nil(t, outcome.Executed) + // Publication was attempted without acknowledgement, so the report must + // stay unknown rather than claim the target was never published. + require.Nil(t, outcome.Executed, "%s: publication evidence must be unknown: %s", test, result.Stdout) require.Equal(t, "unknown", outcome.ChangeState) case "recheck": require.ErrorIs(t, applyErr, ErrPrecondition) require.Equal(t, "failed", outcome.PreconditionStatus) require.Equal(t, SHA256Hex([]byte("other writer\n")), outcome.PreconditionSHA256) - require.False(t, *outcome.Executed) + require.False(t, requireApplyExecuted(t, test, result, outcome, applyErr)) require.True(t, outcome.BackupVerified) require.Equal(t, "unchanged", outcome.ChangeState) default: require.NoError(t, applyErr, "%s", result.Stderr) require.True(t, outcome.Verified) require.Equal(t, SHA256Hex(payload), outcome.AfterSHA256) - require.Equal(t, test != "noop", *outcome.Executed) + require.Equal(t, test != "noop", requireApplyExecuted(t, test, result, outcome, applyErr)) } if test != "precondition" && test != "noop" { require.True(t, outcome.BackupVerified) @@ -129,7 +130,7 @@ func TestApplySudoScriptEvidenceAndCleanup(t *testing.T) { result.Stdout, result.ExitCode = strings.Join(lines[:len(lines)-1], "\n"), -1 partial, lostErr := parseApplyScriptReport(result) require.ErrorIs(t, lostErr, ErrApplyVerification) - require.True(t, *partial.Executed) + require.True(t, requireApplyExecuted(t, "replace (truncated report)", result, partial, lostErr)) require.Equal(t, "changed", partial.ChangeState) require.True(t, partial.BackupVerified) require.False(t, partial.Verified) @@ -165,19 +166,56 @@ func applyScriptEnv(env []string) []string { return out } +// runApplyScriptFixture runs the generated privileged script the way the remote +// host does: as a script file, with the child's stdout and stderr going to real +// files. Piping the script through the child's stdin or capturing it into +// bytes.Buffer values makes os/exec add pipes and copier goroutines, and a child +// that finishes while a copier unwinds can then observe EPIPE/SIGPIPE and report +// a truncated privileged result instead of the behavior under test (issue #83). func runApplyScriptFixture(t *testing.T, script []byte, env []string) ExecResult { t.Helper() - var stdout, stderr bytes.Buffer - cmd := exec.Command("sh") // #nosec G204 -- executes only the generated apply script in the owned fixture directory. - cmd.Stdin, cmd.Stdout, cmd.Stderr, cmd.Env = bytes.NewReader(script), &stdout, &stderr, applyScriptEnv(env) + dir := t.TempDir() + scriptPath := filepath.Join(dir, "apply.sh") + require.NoError(t, os.WriteFile(scriptPath, script, 0o700)) // #nosec G306 -- owned fixture script. + stdoutPath, stderrPath := filepath.Join(dir, "stdout"), filepath.Join(dir, "stderr") + stdout, createErr := os.Create(stdoutPath) // #nosec G304 -- path is confined to the owned fixture directory. + require.NoError(t, createErr) + stderr, createErr := os.Create(stderrPath) // #nosec G304 -- path is confined to the owned fixture directory. + require.NoError(t, createErr) + cmd := exec.Command("sh", scriptPath) // #nosec G204 -- executes only the generated apply script in the owned fixture directory. + cmd.Stdout, cmd.Stderr, cmd.Env = stdout, stderr, applyScriptEnv(env) err := cmd.Run() + require.NoError(t, stdout.Close()) + require.NoError(t, stderr.Close()) code := 0 if err != nil { var exit *exec.ExitError require.True(t, errors.As(err, &exit), "%v", err) code = exit.ExitCode() } - return ExecResult{ExitCode: code, Stdout: stdout.String(), Stderr: stderr.String(), Started: true, ExitObserved: true} + out, readErr := os.ReadFile(stdoutPath) // #nosec G304 -- path is confined to the owned fixture directory. + require.NoError(t, readErr) + errOut, readErr := os.ReadFile(stderrPath) // #nosec G304 -- path is confined to the owned fixture directory. + require.NoError(t, readErr) + return ExecResult{ExitCode: code, Stdout: string(out), Stderr: string(errOut), Started: true, ExitObserved: true} +} + +// requireApplyOutcome reports a missing privileged report with the fixture's +// captured evidence instead of panicking on a nil pointer: a truncated report +// must fail the subtest readably, not abort the package run (issue #83). +func requireApplyOutcome(t *testing.T, stage string, result ExecResult, outcome *ApplyOutcome, parseErr error) *ApplyOutcome { + t.Helper() + require.NotNil(t, outcome, "%s: no privileged report: exit %d; stdout %q; stderr %q; parse error %v", stage, result.ExitCode, result.Stdout, result.Stderr, parseErr) + return outcome +} + +// requireApplyExecuted returns the publication evidence without dereferencing a +// nil pointer: an absent or truncated report must fail the subtest readably. +func requireApplyExecuted(t *testing.T, stage string, result ExecResult, outcome *ApplyOutcome, parseErr error) bool { + t.Helper() + requireApplyOutcome(t, stage, result, outcome, parseErr) + require.NotNil(t, outcome.Executed, "%s: publication evidence missing: exit %d; stdout %q; stderr %q; parse error %v", stage, result.ExitCode, result.Stdout, result.Stderr, parseErr) + return *outcome.Executed } func TestApplySudoCreatesEmptyAndDoesNotRemoveUnownedTemp(t *testing.T) { @@ -193,6 +231,7 @@ func TestApplySudoCreatesEmptyAndDoesNotRemoveUnownedTemp(t *testing.T) { require.NoError(t, err) result := runApplyScriptFixture(t, script, os.Environ()) outcome, parseErr := parseApplyScriptReport(result) + requireApplyOutcome(t, "empty", result, outcome, parseErr) require.NoError(t, parseErr, "%s", result.Stderr) require.True(t, outcome.Created) require.True(t, outcome.Verified) @@ -210,8 +249,9 @@ func TestApplySudoCreatesEmptyAndDoesNotRemoveUnownedTemp(t *testing.T) { require.NoError(t, err) result = runApplyScriptFixture(t, script, os.Environ()) outcome, parseErr = parseApplyScriptReport(result) + requireApplyOutcome(t, "unowned temp", result, outcome, parseErr) require.Error(t, parseErr) - require.False(t, *outcome.Executed) + require.False(t, requireApplyExecuted(t, "unowned temp", result, outcome, parseErr)) retained, readErr := os.ReadFile(unowned) // #nosec G304 -- path is confined to the owned test fixture. require.NoError(t, readErr) require.Equal(t, "do not remove", string(retained)) @@ -247,6 +287,7 @@ func TestApplySudoReportStrictValidation(t *testing.T) { } partial, nonzeroErr := parseApplyScriptReport(ExecResult{ExitCode: 1, Stdout: string(valid)}) require.ErrorIs(t, nonzeroErr, ErrApplyVerification) + require.NotNil(t, partial, "%v", nonzeroErr) require.Equal(t, report.Before, partial.BeforeSHA256) require.Equal(t, report.After, partial.AfterSHA256) require.False(t, partial.Verified) @@ -255,8 +296,10 @@ func TestApplySudoReportStrictValidation(t *testing.T) { require.NoError(t, err) partial, parseErr := parseApplyScriptReport(ExecResult{ExitCode: -1, Stdout: string(progress) + "\n{\"status\":"}) require.ErrorIs(t, parseErr, ErrApplyVerification) + require.NotNil(t, partial, "%v", parseErr) require.Equal(t, report.Before, partial.BeforeSHA256) require.Equal(t, report.After, partial.AfterSHA256) + require.NotNil(t, partial.Executed, "%v", parseErr) require.True(t, *partial.Executed) } @@ -264,6 +307,7 @@ func TestApplySudoUnacknowledgedScriptStartIsUnknown(t *testing.T) { req := ApplyRequest{RemotePath: "/app.conf", Payload: []byte("new")} outcome, err := applySudoOutcome(req, ExecResult{ExitCode: -1, StartAttempted: true}) require.ErrorIs(t, err, ErrApplyVerification) + require.NotNil(t, outcome, "%v", err) require.Nil(t, outcome.Executed) require.Equal(t, "unknown", outcome.ChangeState) require.Equal(t, "unknown", outcome.Verification) @@ -271,7 +315,8 @@ func TestApplySudoUnacknowledgedScriptStartIsUnknown(t *testing.T) { outcome, err = applySudoOutcome(req, ExecResult{ExitCode: -1}) require.Error(t, err) - require.NotNil(t, outcome.Executed) + require.NotNil(t, outcome, "%v", err) + require.NotNil(t, outcome.Executed, "%v", err) require.False(t, *outcome.Executed) require.Equal(t, "unchanged", outcome.ChangeState) } diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 3c10daf..5c85a2b 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -131,7 +131,12 @@ type Config struct { PluginPrivilege string PluginTemplate string PluginFixture string - PluginReplace bool + // PluginSource is the install positional: the local plugin directory that + // `sshx plugin install` publishes into the runtime plugin root. + PluginSource string + // PluginTrust trusts the installed digest in the same install step. + PluginTrust bool + PluginReplace bool // Agent skill lifecycle fields (Mode == "skill"). SkillAction string @@ -175,6 +180,9 @@ type Config struct { // Guarded SQL execution fields (Mode == "sql"). SQLStatement string + // SQLStatementFile is a local file holding the statement, used when no + // positional statement is given (--statement-file=PATH). + SQLStatementFile string // SQLEngine names the database engine: "postgres" (default) or "sqlite". SQLEngine string SQLDatabase string @@ -252,7 +260,21 @@ type Config struct { TextMaxScanBytes int64 TextUseSudo bool TextRedact bool - TextHelp bool + + // HelpVerb is the subcommand whose usage document was requested with + // `sshx --help`. It short-circuits parsing and execution: the verb's + // own usage is printed instead of running anything. + HelpVerb string + + // ShowUsage requests the global `sshx --help` surface. It is answered before + // any mode runs, so a usage request never resolves a host, connects, or + // writes trust state. + ShowUsage bool + + // Quiet suppresses human notices on stderr (deprecation warnings, narration + // log lines). It never changes stdout, the exit code, or the machine result, + // so a caller that merges stderr under --json can still parse stdout. + Quiet bool // Interactive login fields (Mode == "login"). LoginUseSudo bool diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index d67bfa9..9385f8e 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -40,6 +40,16 @@ sshx plugin list --json sshx inspect -h=prod-web system.baseline --json ``` +`sshx plugin list` always names the local plugin root and its provenance, and a +missing plugin names the directory that was searched. When a plugin already +exists as a local directory, provision it through the CLI instead of placing +files by hand: + +```bash +sshx plugin install ./my-plugin --trust --json # validate + publish + trust +sshx plugin install ./my-plugin --replace --json # keep the old one as a backup +``` + Application-specific scripts are sshx runtime assets under `~/.sshx/plugins/` (or `$SSHX_HOME/plugins/`). **Do not embed or maintain collector scripts in this skill.** If no suitable plugin exists, ask sshx to @@ -112,6 +122,16 @@ Branch on `success` / `status` first; on failure read `error.kind` or `error_kind` (do not parse free-form text). For change actions, inspect `completion` before any retry (`not_started|partial|completed|completed_unconfirmed|unknown`). +Every subcommand documents itself: `sshx --help` prints that verb's +usage, and `sshx --help --json` returns the same blocks as +`sshx.help.v1` (`sshx text --help --json` keeps its structured +`sshx.text.help.v1` document). Read the verb's help before guessing flag names. + +Under `--json`, stdout carries the machine document and nothing else; human +notices (deprecation warnings, narration) go to stderr. Pass `--quiet` when you +merge the streams (`2>&1`): it suppresses the notices, so the merged stream still +parses while failures keep their JSON `error_kind`/`error`. + Selectors resolve only configured host aliases. Literal addresses require `--address=` and cannot enter group/tag fan-out. Zero matches is exit `255` with no network access. @@ -322,6 +342,11 @@ automatic backup → execute → structured result + audit event. # Reads run directly; no EXPLAIN, no backup. sshx sql -h=db1 --db=app --json "SELECT count(*) FROM users" +# A statement that opens with a comment is statement text, not an option; a +# .sql file can be handed over directly, or piped on stdin. +sshx sql -h=db1 --db=app --json --statement-file=./query.sql +printf '%s' 'select 1' | sshx sql -h=db1 --db=app --json + # DML with WHERE: rows are snapshotted to CSV on the remote host first. sshx sql -h=db1 --db=app --db-user=app --db-password-key=app-db --json \ "UPDATE users SET active=false WHERE id=42" diff --git a/tests/e2e/host_audit_e2e_test.go b/tests/e2e/host_audit_e2e_test.go index 82b8e7f..4de173b 100644 --- a/tests/e2e/host_audit_e2e_test.go +++ b/tests/e2e/host_audit_e2e_test.go @@ -84,6 +84,91 @@ func TestCLIAuditQueryByRunID(t *testing.T) { assert.Equal(t, 0, emptyDoc.Count) } +// `sshx run --target=` resolves the sudo key per host, and the audit trail must +// name the reference each host actually used: the per-target event carries the +// resolved key, while the run summary reports only a caller-level choice instead +// of misreporting the built-in default (issue #78). +func TestCLIRunAuditRecordsResolvedPerTargetSudoKey(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + auditDir := filepath.Join(home, "audit") + env := map[string]string{ + "SSHX_E2E_KEYRING_FILE": filepath.Join(home, "keyring.json"), + "SSH_PASSWORD": operatorPassword, + "SSHX_NO_AUDIT": "false", + } + // The host's sudo key holds the operator password; "master" is never seeded, + // so a run that fell back to the default could not authenticate sudo. + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=hostkey:" + operatorPassword, "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + writeSettings(t, home, map[string]any{ + "hosts": []map[string]any{{ + "name": "prod-web", "host": server.host, "port": server.port, "user": "operator", + "sudo_password_key": "hostkey", + }}, + }) + + auditEventsFor := func(t *testing.T, runID string) []map[string]any { + t.Helper() + queried := runSSHXWithTestKeyring(t, home, []string{ + "audit", "query", "--run-id=" + runID, "--json", "--audit-output=" + auditDir, "--no-audit", + }, env) + require.Equal(t, 0, queried.exitCode, queried.stderr) + var queryDoc struct { + Events []map[string]any `json:"events"` + } + require.NoError(t, json.Unmarshal([]byte(queried.stdout), &queryDoc)) + require.NotEmpty(t, queryDoc.Events, queried.stdout) + return queryDoc.Events + } + splitEvents := func(t *testing.T, events []map[string]any) (summary, target map[string]any) { + t.Helper() + for _, event := range events { + if _, ok := event["target_index"]; ok { + target = event + continue + } + summary = event + } + require.NotNil(t, summary, "run must write a summary audit event") + require.NotNil(t, target, "run must write a per-target audit event") + return summary, target + } + + ran := runSSHXWithTestKeyring(t, home, []string{ + "run", "--target=prod-web", "--no-key", "--accept-unknown-host", + "--json", "--audit-output=" + auditDir, "--", "sudo whoami", + }, env) + require.Equal(t, 0, ran.exitCode, "stderr=%s stdout=%s", ran.stderr, ran.stdout) + var runDoc struct { + RunID string `json:"run_id"` + } + require.NoError(t, json.Unmarshal([]byte(ran.stdout), &runDoc)) + require.NotEmpty(t, runDoc.RunID) + + summary, target := splitEvents(t, auditEventsFor(t, runDoc.RunID)) + assert.Equal(t, "hostkey", target["sudo_key"], + "the per-target event must name the sudo key the host resolved to") + assert.NotContains(t, summary, "sudo_key", + "a caller that chose no key must not be recorded as using the built-in default") + + // An explicit caller key overrides the host's key and is recorded as such. + explicitSet := runSSHXWithTestKeyring(t, home, []string{"--password-set=explicit:" + operatorPassword, "--no-audit"}, env) + require.Equal(t, 0, explicitSet.exitCode, explicitSet.stderr) + override := runSSHXWithTestKeyring(t, home, []string{ + "run", "--target=prod-web", "-pk=explicit", "--no-key", "--accept-unknown-host", + "--json", "--audit-output=" + auditDir, "--", "sudo whoami", + }, env) + require.Equal(t, 0, override.exitCode, "stderr=%s stdout=%s", override.stderr, override.stdout) + var overrideDoc struct { + RunID string `json:"run_id"` + } + require.NoError(t, json.Unmarshal([]byte(override.stdout), &overrideDoc)) + overrideSummary, overrideTarget := splitEvents(t, auditEventsFor(t, overrideDoc.RunID)) + assert.Equal(t, "explicit", overrideTarget["sudo_key"]) + assert.Equal(t, "explicit", overrideSummary["sudo_key"]) +} + func TestCLIHostImportIsUsableAndFailedSelectionIsAllOrNothing(t *testing.T) { server := startSSHServer(t, serverOptions{}) home := t.TempDir() diff --git a/tests/e2e/inspect_plugin_e2e_test.go b/tests/e2e/inspect_plugin_e2e_test.go index b8b9a50..ef712ad 100644 --- a/tests/e2e/inspect_plugin_e2e_test.go +++ b/tests/e2e/inspect_plugin_e2e_test.go @@ -2,6 +2,7 @@ package e2e import ( "encoding/json" + "io/fs" "os" "path/filepath" "strings" @@ -115,6 +116,164 @@ func TestCLIPluginLifecycleCreatesValidRecoverableRuntimeAssets(t *testing.T) { assert.NoDirExists(t, createResult.Path) } +// `sshx plugin install ` is the audited provisioning path: it publishes a +// local plugin directory into the runtime plugin root with sshx's own modes, +// validates it before publishing, and can trust the digest in the same step. +func TestCLIPluginInstallProvisionsFromALocalDirectory(t *testing.T) { + home := t.TempDir() + runtimeRoot := filepath.Join(t.TempDir(), "agent-runtime") + runtimeEnv := map[string]string{"SSHX_HOME": runtimeRoot} + + // Stage the source outside the runtime root, the way an operator would. + scaffold := runSSHX(t, home, []string{"plugin", "create", "private.environment", "--privilege=optional", "--json"}, + map[string]string{"SSHX_HOME": filepath.Join(t.TempDir(), "scaffold-runtime")}) + require.Equal(t, 0, scaffold.exitCode, scaffold.stderr) + var scaffoldResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(scaffold.stdout), &scaffoldResult)) + source := filepath.Join(t.TempDir(), "staging", "private.environment") + copyPluginTreeForTest(t, scaffoldResult.Path, source) + // A checked-out plugin carries caller-owned modes; install must not inherit them. + manifestSource := filepath.Join(source, pluginpkg.ManifestFile) + collectorSourcePath := filepath.Join(source, "collectors", "linux.sh") + require.NoError(t, os.Chmod(manifestSource, 0o644)) // #nosec G302 -- fixture stages caller-owned source modes on purpose. + require.NoError(t, os.Chmod(collectorSourcePath, 0o755)) // #nosec G302 -- fixture stages caller-owned source modes on purpose. + + // A missing plugin names the directory that was searched. + missing := runSSHX(t, home, []string{"plugin", "show", "private.environment", "--json"}, runtimeEnv) + assert.Equal(t, 255, missing.exitCode) + assert.Contains(t, missing.stdout, filepath.Join(runtimeRoot, "plugins")) + + installed := runSSHX(t, home, []string{"plugin", "install", source, "--json"}, runtimeEnv) + require.Equal(t, 0, installed.exitCode, installed.stderr) + var installResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(installed.stdout), &installResult)) + assert.True(t, installResult.Success) + assert.Equal(t, "private.environment", installResult.PluginID) + assert.Equal(t, filepath.Join(runtimeRoot, "plugins", "private.environment"), installResult.Path) + assert.Equal(t, filepath.Join(runtimeRoot, "plugins"), installResult.PluginRoot) + assert.False(t, installResult.Trusted) + assert.Len(t, installResult.Files, 6) + for _, relative := range installResult.Files { + info, err := os.Stat(filepath.Join(installResult.Path, relative)) + require.NoError(t, err) + want := os.FileMode(0o600) + if strings.HasPrefix(relative, "collectors/") { + want = 0o700 + } + assert.Equal(t, want, info.Mode().Perm(), relative) + } + + // The installed plugin is immediately usable, but still untrusted. + tested := runSSHX(t, home, []string{"plugin", "test", "private.environment", "--fixture=complete", "--json"}, runtimeEnv) + require.Equal(t, 0, tested.exitCode, tested.stderr) + untrusted := runSSHX(t, home, []string{"plugin", "show", "private.environment", "--json"}, runtimeEnv) + require.Equal(t, 0, untrusted.exitCode, untrusted.stderr) + var untrustedResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(untrusted.stdout), &untrustedResult)) + assert.False(t, untrustedResult.Trusted) + assert.False(t, untrustedResult.Builtin) + assert.Equal(t, installResult.Digest, untrustedResult.Digest) + + // Re-installing needs --replace; the previous plugin stays recoverable and the + // digest follows the content, so the replacement must be re-trusted. + duplicate := runSSHX(t, home, []string{"plugin", "install", source, "--json"}, runtimeEnv) + assert.Equal(t, 255, duplicate.exitCode) + var duplicateResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(duplicate.stdout), &duplicateResult)) + assert.Equal(t, "already_exists", duplicateResult.ErrorKind) + + // The digest covers identity-bearing content: changing the collector changes + // the digest, so the replaced plugin is untrusted until it is trusted again. + trustedFirst := runSSHX(t, home, []string{"plugin", "trust", "private.environment", "--json"}, runtimeEnv) + require.Equal(t, 0, trustedFirst.exitCode, trustedFirst.stderr) + collectorSource := collectorSourcePath + collectorBytes, readErr := os.ReadFile(collectorSource) // #nosec G304 -- fixture reads its own staged source. + require.NoError(t, readErr) + require.NoError(t, os.WriteFile(collectorSource, append(collectorBytes, []byte("# edited\n")...), 0o755)) // #nosec G306,G703 -- fixture stages a permissive source collector. + + replaced := runSSHX(t, home, []string{"plugin", "install", source, "--replace", "--json"}, runtimeEnv) + require.Equal(t, 0, replaced.exitCode, replaced.stderr) + var replaceResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(replaced.stdout), &replaceResult)) + assert.NotEmpty(t, replaceResult.BackupPath) + _, err := os.Stat(filepath.Join(replaceResult.BackupPath, pluginpkg.ManifestFile)) + require.NoError(t, err) + assert.False(t, replaceResult.Trusted, "replaced content must be trusted again explicitly") + assert.NotEqual(t, installResult.Digest, replaceResult.Digest) + + retrusted := runSSHX(t, home, []string{"plugin", "install", source, "--replace", "--trust", "--json"}, runtimeEnv) + require.Equal(t, 0, retrusted.exitCode, retrusted.stderr) + var retrustResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(retrusted.stdout), &retrustResult)) + assert.True(t, retrustResult.Trusted) + assert.Equal(t, replaceResult.Digest, retrustResult.Digest) + + // A source that cannot be validated never replaces the published plugin. + broken := filepath.Join(t.TempDir(), "broken", "private.environment") + copyPluginTreeForTest(t, source, broken) + require.NoError(t, os.Remove(filepath.Join(broken, "collectors", "linux.sh"))) + refused := runSSHX(t, home, []string{"plugin", "install", broken, "--replace", "--json"}, runtimeEnv) + assert.Equal(t, 255, refused.exitCode) + var refusedResult pluginpkg.ActionResult + require.NoError(t, json.Unmarshal([]byte(refused.stdout), &refusedResult)) + assert.Contains(t, refusedResult.Error, "validate source plugin") + stillThere := runSSHX(t, home, []string{"plugin", "validate", "private.environment", "--json"}, runtimeEnv) + require.Equal(t, 0, stillThere.exitCode, stillThere.stderr) + + // A symlink in the source is refused: a plugin holds plain files only. + symlinked := filepath.Join(t.TempDir(), "symlinked", "private.environment") + copyPluginTreeForTest(t, source, symlinked) + if symlinkErr := os.Symlink(filepath.Join(symlinked, pluginpkg.ManifestFile), filepath.Join(symlinked, "alias.json")); symlinkErr != nil { + t.Logf("skipping symlink assertion: %v", symlinkErr) + } else { + refusedLink := runSSHX(t, home, []string{"plugin", "install", symlinked, "--json"}, runtimeEnv) + assert.Equal(t, 255, refusedLink.exitCode) + assert.Contains(t, refusedLink.stdout, "symlink") + } + + // The inventory groups provenance, names the local root, and makes an empty + // local set visible instead of inferring it from missing rows. + listed := runSSHX(t, home, []string{"plugin", "list"}, runtimeEnv) + require.Equal(t, 0, listed.exitCode, listed.stderr) + assert.Contains(t, listed.stdout, "built-in capabilities (8):") + assert.Contains(t, listed.stdout, "local plugins (1) in "+filepath.Join(runtimeRoot, "plugins")) + assert.Contains(t, listed.stdout, "trusted=true") + empty := runSSHX(t, home, []string{"plugin", "list"}, map[string]string{"SSHX_HOME": filepath.Join(t.TempDir(), "empty-runtime")}) + require.Equal(t, 0, empty.exitCode, empty.stderr) + assert.Contains(t, empty.stdout, "local plugins (0)") + assert.Contains(t, empty.stdout, "(none;") +} + +// copyPluginTreeForTest stages a plugin directory outside the runtime root with +// rooted handles, so the fixture cannot escape either tree while copying. +func copyPluginTreeForTest(t *testing.T, from, to string) { + t.Helper() + require.NoError(t, os.MkdirAll(to, 0o750)) // #nosec G301 -- fixture stages a caller-owned source layout. + sourceRoot, err := os.OpenRoot(from) + require.NoError(t, err) + defer func() { _ = sourceRoot.Close() }() //nolint:errcheck // fixture handle cleanup + stageRoot, err := os.OpenRoot(to) + require.NoError(t, err) + defer func() { _ = stageRoot.Close() }() //nolint:errcheck // fixture handle cleanup + walkErr := fs.WalkDir(sourceRoot.FS(), ".", func(relative string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if relative == "." { + return nil + } + if entry.IsDir() { + return stageRoot.Mkdir(relative, 0o750) + } + data, readErr := sourceRoot.ReadFile(relative) + if readErr != nil { + return readErr + } + return stageRoot.WriteFile(relative, data, 0o600) // #nosec G306 -- fixture copy of its own scaffold. + }) + require.NoError(t, walkErr) +} + func TestCLIInspectionTrustExecutionRedactionAndRemoteCache(t *testing.T) { server := startSSHServer(t, serverOptions{}) home := t.TempDir()