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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.19.0] - 2026-09-24

### Added

- `sshx sql --target=<name>` as an alias for `-h` / `--host`, matching the
target selector used by `run` and `apply`.
- `sshx sql --allow-full-table-backup` as an explicit opt-in for row-filtered
mutations whose before-image must include the full table.

### Changed

- Row-filtered SQL mutations now use a narrow before-image when the selected
rows can be safely reproduced. If backup planning must widen to a full-table
snapshot, execution is blocked by default with a stable reason code; dry-run
and JSON results expose the planned backup scope.
- `sshx apply` checks parent-directory write and execute permission before
creating backup or temporary files, returning
`parent_directory_not_writable` and suggesting `--sudo` when a sudo
credential is configured.
- `sshx run --help` distinguishes `--sudo` for the selected script interpreter
from nested `sudo` commands in the script, whose stdin prevents password
injection.

## [0.18.0] - 2026-09-23

### Added
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,14 @@ printf '%s' 'SELECT count(*) FROM users' | sshx sql -h=prod-db --db=app --json
`--allow-full-table`. Destructive DDL requires `--force --no-backup`; sshx does
not claim an automatic restorable backup for schema destruction. Skipping a DML
backup also requires both `--no-backup` and `--force`. Small changes receive a
row CSV snapshot; complex or large changes receive a full-table CSV snapshot
under `~/.sshx/sql-backups/`. Backup and mutation run in one PostgreSQL
row CSV snapshot. If a row-filtered mutation cannot be backed up narrowly, or
the EXPLAIN estimate exceeds `--row-threshold`, the full-table before-image is
blocked by default (`unreproducible_select` or
`full_table_backup_requires_opt_in`). Pass `--allow-full-table-backup` to
explicitly permit that wider backup; it does not replace `--allow-full-table`
for a mutation without `WHERE`. SQLite likewise uses row CSV for stable
predicates and blocks an unreproducible full-table fallback by default.
Backups land under `~/.sshx/sql-backups/`. Backup and mutation run in one PostgreSQL
transaction while holding a target-table write lock, closing the concurrency
window between them. Catalog preflight blocks automatic execution when
triggers, rewrite rules, partitions, or cascading referential actions can
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support

| Version | Supported |
| -------- | ------------------ |
| 0.19.x | :white_check_mark: |
| 0.18.x | :white_check_mark: |
| 0.17.x | :white_check_mark: |
| < 0.17.0 | :x: |
| < 0.18.0 | :x: |

Security updates are provided for the latest minor release and the previous
minor release (N-1). Older lines do not receive patches; please upgrade.
Expand Down
1 change: 1 addition & 0 deletions docs/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ cat ./check.sh | sshx run --target=prod-web --script-stdin --json

- Selectors resolve configured hosts only. Use `--address=` for one literal address.
- Script payloads are streamed on SSH stdin and are not reconstructed through shell joining.
- `--sudo` runs the selected script interpreter as a whole via sudo. Do not embed sudo commands in a script: the script occupies stdin, so sshx cannot inject the password for nested sudo.
- The script's `#!` line selects the interpreter, so a `#!/usr/bin/env bash` payload keeps bash semantics (`set -o pipefail`, arrays, `[[ ]]`). Use `--shell=NAME` to override it. Supported: `sh`, `bash`, `zsh`, `dash`, `ksh`, `ash`; any other interpreter is rejected as `error_kind: config` without connecting. The choice appears as `action.script_runner`.
- Dry-run and results expose payload SHA-256 and byte length, not raw script contents.
- Multi-target `--jsonl` streams `run_started`, per-target events, and `run_finished`.
Expand Down
8 changes: 8 additions & 0 deletions docs/apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ approval meanings. It cannot bypass `--expect-plan`.

SFTP runs as the SSH user. Use `--sudo` when the target is not writable by that user. sshx stages the payload under the remote home directory, then runs a privileged stdin script to install it. The script is never left on the host.

Before writing a backup or replacement temp, `apply` checks that the target's
parent directory is writable and searchable by the SSH user. Atomic replacement
requires both write and execute permission on that directory, regardless of
the target file's owner. A denial returns
`error_kind: parent_directory_not_writable`; when a sudo password key is
configured, the error suggests `--sudo`. This preflight cannot prevent a
concurrent permission change after the check.

```bash
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --sudo --json
Expand Down
2 changes: 1 addition & 1 deletion docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ renaming, or changing the meaning of a field, flag, exit code, or
| Text dissection | `sshx.text.v1` | `sshx text --json`; help document is `sshx.text.help.v1` |
| Exit codes | `0`, `1..254`, `255` | Remote status vs sshx-level failure |
| JSON sshx failure | `exit_code: -1` | Distinguishes a remote `exit 255` |
| `error_kind` | `timeout`, `auth`, `host_key`, `connect`, `blocked`, `exit_missing`, `config`, `error`, plus SQL/apply additions | Branch on this field, not prose |
| `error_kind` | `timeout`, `auth`, `host_key`, `connect`, `blocked`, `exit_missing`, `config`, `error`, plus SQL/apply additions including `unreproducible_select`, `full_table_backup_requires_opt_in`, and `parent_directory_not_writable` | Branch on this field, not prose |
| JSONL event types | `run_started`, `target_started`, `target_finished`, `run_finished` | |

CLI flags listed in `sshx --help` for a released minor version remain valid
Expand Down
5 changes: 5 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Claude Desktop / generic MCP client entry:
| `sshx_transfer` | `--transfer` | Server-to-server streaming through the local machine |
| `sshx_host_list` | `--host-list --json` | Read-only `sshx.hosts.v1` inventory |

For row-filtered SQL, a backup that would widen to a full-table snapshot is
blocked by default. Set `allow_full_table_backup: true` on `sshx_sql` only when
that broader before-image is intentional; this does not authorize an
`UPDATE`/`DELETE` without `WHERE`.

Tool results contain the CLI's versioned JSON verbatim (for example
`sshx.result.v1` from `sshx_run`), so `success`, `error_kind`, `completion`,
and retry guidance keep exactly the semantics documented for the CLI. A
Expand Down
1 change: 1 addition & 0 deletions docs/zh/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sshx run --target=prod-web --script-file=./check.sh --dry-run --json

- 选择器只解析已配置主机;字面地址用 `--address=`,不能进入 group/tag 扩散。
- 脚本经 SSH stdin 原样传输,不经本地 `strings.Join` 拼装。
- `--sudo` 会把选定的脚本解释器整体交给 sudo 运行。不要在脚本里再嵌套 sudo 命令:脚本占用了 stdin,sshx 无法再向嵌套 sudo 注入密码。
- 脚本的 `#!` 行决定解释器,`#!/usr/bin/env bash` 会真正用 bash 执行(`set -o pipefail`、数组、`[[ ]]` 都可用)。可用 `--shell=NAME` 覆盖。支持 `sh`、`bash`、`zsh`、`dash`、`ksh`、`ash`;其他解释器在本地就以 `error_kind: config` 拒绝,不建立连接。最终解释器体现在 `action.script_runner`。
- dry-run/结果暴露 payload SHA-256 与字节数,默认不回传脚本全文。
- 多主机 `--jsonl` 输出 `run_started` / `target_*` / `run_finished`。
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ POSIX-rename 扩展返回 `SSH_FX_OP_UNSUPPORTED` 时可尝试普通 rename,

SFTP 以 SSH 用户身份运行。目标对该用户不可写时使用 `--sudo`。sshx 先把 payload 暂存到远端 home,再通过 stdin 执行特权安装脚本;脚本不会留在主机上。

写入备份或替换临时文件前,`apply` 会先检查 SSH 用户是否能写入并访问目标父目录。
原子替换要求父目录同时具备写入和执行权限,与目标文件所有者无关。权限不足时返回
`error_kind: parent_directory_not_writable`;已配置 sudo 密码 key 时会提示 `--sudo`。
该预检不能阻止检查后发生的并发权限变更。

校验和 reload 用另一次 `sshx run`:

```bash
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Claude Desktop / 通用 MCP 客户端条目:
| `sshx_transfer` | `--transfer` | 经本机中转的服务器到服务器流式传输 |
| `sshx_host_list` | `--host-list --json` | 只读 `sshx.hosts.v1` 清单 |

带行过滤条件的 SQL 若需扩大为整表快照,默认会阻断。只有确实接受更宽的 before-image
时,才在 `sshx_sql` 中设置 `allow_full_table_backup: true`;这不允许没有 `WHERE` 的
`UPDATE` / `DELETE`。

工具结果就是 CLI 的版本化 JSON(例如 `sshx_run` 的 `sshx.result.v1`),因此
`success`、`error_kind`、`completion` 和重试指引与 CLI 文档完全一致。子进程
非零退出会把 MCP 结果标成 tool error,但保留结构化载荷。
Expand Down
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ func resolveHostFromSettings(config *sshclient.Config) error {
sudoKey := hostConfig.EffectiveSudoPasswordKey()
if sudoKey != "" && !sudoKeyChosen(config) {
config.SudoKey = sudoKey
config.SudoKeyConfigured = true
logger.GetLogger().Success("Using sudo password key: %s", sudoKey)
}
// SSH login password key is a distinct role and never falls back to sudo keys.
Expand Down
15 changes: 8 additions & 7 deletions internal/app/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,14 @@ func HandleApply(config *sshclient.Config, audit *auditRecorder) (err error) {

run.phase = "apply"
outcome, applyErr := client.ApplyRegularFile(sshclient.ApplyRequest{
RemotePath: config.RemotePath,
Payload: payload,
ExpectSHA256: config.ApplyExpectSHA256,
Backup: !config.ApplyNoBackup,
BackupDir: config.ApplyBackupDir,
Force: config.Force,
UseSudo: config.ApplyUseSudo,
RemotePath: config.RemotePath,
Payload: payload,
ExpectSHA256: config.ApplyExpectSHA256,
Backup: !config.ApplyNoBackup,
BackupDir: config.ApplyBackupDir,
Force: config.Force,
UseSudo: config.ApplyUseSudo,
SudoConfigured: config.SudoKeyConfigured,
})
if applyErr != nil {
run.outcome = outcome
Expand Down
11 changes: 11 additions & 0 deletions internal/app/cli_surface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@ func TestParseArgsSQLStatementSources(t *testing.T) {
require.Contains(t, missing.ArgumentError, "read --statement-file")
}

func TestParseArgsSQLTargetAlias(t *testing.T) {
for _, selector := range []string{"--target=db", "--host=db", "-h=db"} {
t.Run(selector, func(t *testing.T) {
config := ParseArgs([]string{"sshx", "sql", selector, "--db=app", "SELECT 1"})
require.Empty(t, config.ArgumentError)
require.Equal(t, "db", config.Host)
require.Equal(t, "SELECT 1", config.SQLStatement)
})
}
}

// 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) {
Expand Down
10 changes: 7 additions & 3 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func applySudoKeyFlag(config *sshclient.Config, arg string) bool {
case strings.HasPrefix(arg, "-pk="), strings.HasPrefix(arg, "--password-key="), strings.HasPrefix(arg, "--sudo-password-key="):
config.SudoKey = strings.SplitN(arg, "=", 2)[1]
config.SudoKeySet = true
config.SudoKeyConfigured = config.SudoKey != ""
return true
default:
return false
Expand Down Expand Up @@ -329,6 +330,7 @@ func ParseArgs(args []string) *sshclient.Config {
}

sudoKey := os.Getenv("SSH_SUDO_KEY")
config.SudoKeyConfigured = sudoKey != ""
if sudoKey == "" {
sudoKey = sshclient.DefaultSudoKey
}
Expand Down Expand Up @@ -835,12 +837,12 @@ func parseRunArgs(config *sshclient.Config, args []string) {
// 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",
"-h", "--host", "--target", "-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",
"--statement-file", "--row-threshold", "--allow-full-table", "--allow-full-table-backup", "--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",
Expand Down Expand Up @@ -926,7 +928,7 @@ func parseSQLArgs(config *sshclient.Config, args []string) {
case applyLifecycleFlag(config, arg):
case strings.HasPrefix(arg, "--bypass-reason="):
config.BypassReason = strings.TrimPrefix(arg, "--bypass-reason=")
case strings.HasPrefix(arg, "-h="), strings.HasPrefix(arg, "--host="):
case strings.HasPrefix(arg, "-h="), strings.HasPrefix(arg, "--host="), strings.HasPrefix(arg, "--target="):
config.Host = strings.SplitN(arg, "=", 2)[1]
case strings.HasPrefix(arg, "-p="), strings.HasPrefix(arg, "--port="):
config.Port = strings.SplitN(arg, "=", 2)[1]
Expand Down Expand Up @@ -977,6 +979,8 @@ func parseSQLArgs(config *sshclient.Config, args []string) {
}
case arg == "--allow-full-table":
config.SQLAllowFullTable = true
case arg == "--allow-full-table-backup":
config.SQLAllowFullTableBackup = true
case arg == "--no-backup":
config.SQLNoBackup = true
case arg == "--explain":
Expand Down
50 changes: 31 additions & 19 deletions internal/app/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,23 +141,24 @@ type applyDryRunPlan struct {
// The backup decision shown here uses no EXPLAIN estimate; a row-level backup
// may still upgrade to a table dump at execution time.
type sqlDryRunPlan struct {
Engine string `json:"engine"`
Database string `json:"database"`
Statement string `json:"statement"`
StatementHash string `json:"statement_sha256"`
Class string `json:"class,omitempty"`
Verb string `json:"verb,omitempty"`
Table string `json:"table,omitempty"`
HasWhere bool `json:"has_where"`
Docker string `json:"docker,omitempty"`
CredSource string `json:"cred_source,omitempty"`
CredCache string `json:"cred_cache,omitempty"`
PolicyCheck dryRunStatus `json:"policy_check"`
BackupKind string `json:"backup_kind,omitempty"`
BackupReason string `json:"backup_reason,omitempty"`
ExplainCommand string `json:"explain_command,omitempty"`
ExecuteCommand string `json:"execute_command,omitempty"`
UseSudo bool `json:"use_sudo,omitempty"`
Engine string `json:"engine"`
Database string `json:"database"`
Statement string `json:"statement"`
StatementHash string `json:"statement_sha256"`
Class string `json:"class,omitempty"`
Verb string `json:"verb,omitempty"`
Table string `json:"table,omitempty"`
HasWhere bool `json:"has_where"`
Docker string `json:"docker,omitempty"`
CredSource string `json:"cred_source,omitempty"`
CredCache string `json:"cred_cache,omitempty"`
PolicyCheck dryRunStatus `json:"policy_check"`
BackupKind string `json:"backup_kind,omitempty"`
BackupReasonCode string `json:"backup_reason_code,omitempty"`
BackupReason string `json:"backup_reason,omitempty"`
ExplainCommand string `json:"explain_command,omitempty"`
ExecuteCommand string `json:"execute_command,omitempty"`
UseSudo bool `json:"use_sudo,omitempty"`
}

func emitDryRunPlan(config *sshclient.Config) error {
Expand Down Expand Up @@ -861,13 +862,24 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) {
return
}
sqlPlan.BackupKind = string(backup.Kind)
sqlPlan.BackupReasonCode = backup.ReasonCode
sqlPlan.BackupReason = backup.Reason
if backup.Kind == sqlsafe.BackupRows {
sqlPlan.BackupReason += " (may upgrade to a full-table CSV snapshot if the EXPLAIN estimate exceeds the row threshold)"
if backup.Kind == sqlsafe.BackupRows && sqlsafe.NormalizeEngine(config.SQLEngine) != sqlsafe.EngineSQLite {
sqlPlan.BackupReason += " (an EXPLAIN estimate above --row-threshold may select a full-table backup, which is blocked by default; pass --allow-full-table-backup to opt in)"
}
if cls.Class == sqlsafe.ClassDML && !opts.NoBackup && backup.Kind != sqlsafe.BackupFile {
sqlPlan.BackupReason += " (runtime catalog preflight blocks triggers, rewrite rules, partitions, and cascading referential actions)"
}
if scopeErr := sqlsafe.CheckBackupScope(cls, backup, opts); scopeErr != nil {
kind := "blocked"
if typed, ok := scopeErr.(interface{ ErrorKind() string }); ok {
kind = typed.ErrorKind()
}
plan.SafetyCheck = dryRunStatus{Status: "blocked", ErrorKind: kind, Message: scopeErr.Error()}
sqlPlan.PolicyCheck = plan.SafetyCheck
plan.Valid = false
return
}

conn := newSQLExecutor(config, "")
if cls.Class == sqlsafe.ClassDML || config.SQLExplainOnly {
Expand Down
12 changes: 12 additions & 0 deletions internal/app/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,19 @@ func reportPlanFailure(config *sshclient.Config, audit *auditRecorder, err error
if classification, classifyErr := sqlsafe.ClassifyFor(config.SQLEngine, config.SQLStatement); classifyErr == nil {
run.cls = classification
}
if prepared := preparedFrom(config); prepared != nil && prepared.preview.SQL != nil {
preview := prepared.preview.SQL
if preview.BackupKind != "" && preview.BackupKind != string(sqlsafe.BackupNone) {
run.backup = &sqlBackupJSON{
Kind: preview.BackupKind, Table: preview.Table,
ReasonCode: preview.BackupReasonCode, Reason: preview.BackupReason,
}
}
}
failure := run.baseResult()
if failure.Backup != nil {
failure.Evidence.BackupStatus = "not_performed"
}
failure.ExitCode, failure.ErrorKind, failure.Error = -1, kind, redactError(err)
value = failure
case "inspect":
Expand Down
Loading
Loading