diff --git a/CHANGELOG.md b/CHANGELOG.md index b76f699..c7b3679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-08-21 + +### Added + +- `sshx sql --sudo` runs the remote `sqlite3`/`psql` client via `sudo -S` so + service-owned database files (typical SQLite under `/var/...`) are reachable + when the SSH user cannot open them. The sudo password is delivered on stdin + ahead of SQL and never appears in argv. Dry-run, JSON (`sudo`), audit + `uses_sudo`, and MCP `sshx_sql` expose the flag. + +### Fixed + +- SQLite reads no longer pass `sqlite3 -readonly`, a flag missing from older + distro clients (sqlite 3.22 on Ubuntu 18.04 and similar). Reads still open + `file:?mode=ro`, which those clients honor and which still rejects writes. +- `sshx sql --json` failures now include remote `stdout`/`stderr` and append the + client error line to `error`, so agents can see why sqlite3/psql exited. + ## [0.7.0] - 2026-08-16 ### Added diff --git a/SECURITY.md b/SECURITY.md index c081ada..fc4cd96 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support | Version | Supported | | ------- | ------------------ | +| 0.8.x | :white_check_mark: | | 0.7.x | :white_check_mark: | -| 0.6.x | :white_check_mark: | -| < 0.6.0 | :x: | +| < 0.7.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. diff --git a/internal/app/audit.go b/internal/app/audit.go index 60a40a0..35f1159 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -413,6 +413,9 @@ func (r *auditRecorder) refresh(config *sshclient.Config) { if config.Mode == "apply" { r.event.UsesSudo = config.ApplyUseSudo } + if config.Mode == "sql" { + r.event.UsesSudo = config.SQLUseSudo + } if config.Mode == "inspect" { if resolved, resolveErr := pluginpkg.Resolve(config.InspectCapability); resolveErr == nil { if useSudo, _, privilegeErr := inspectionPrivilege(config, resolved.Manifest); privilegeErr == nil { @@ -548,7 +551,7 @@ func auditWouldReadSecret(config *sshclient.Config) bool { } return config.InspectUseSudo && config.SudoKey != "" case "sql": - return config.SQLPasswordKey != "" || config.SQLCredFrom != "" + return config.SQLPasswordKey != "" || config.SQLCredFrom != "" || (config.SQLUseSudo && config.SudoKey != "") case "apply": return config.ApplyUseSudo && config.SudoKey != "" default: diff --git a/internal/app/config.go b/internal/app/config.go index 6424c25..bd27de1 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -618,6 +618,8 @@ func parseSQLArgs(config *sshclient.Config, args []string) { } case arg == "--cred-refresh": config.SQLCredRefresh = true + case arg == "--sudo": + config.SQLUseSudo = true case arg == "--force", arg == "-f": config.Force = true case arg == "--dry-run": diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 891a29e..5d7f06f 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -117,6 +117,7 @@ type sqlDryRunPlan struct { 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 { @@ -358,6 +359,10 @@ func fillDryRunSudo(config *sshclient.Config, plan *dryRunPlan) { plan.UsesSudo = config.ApplyUseSudo plan.SudoKey = config.SudoKey } + if config.Mode == "sql" { + plan.UsesSudo = config.SQLUseSudo + plan.SudoKey = config.SudoKey + } if config.Mode == "host" && config.HostAction == "test" { return } @@ -527,7 +532,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { case "sql": plan.WouldConnect = canProceed plan.WouldExecute = canProceed && !config.SQLExplainOnly - plan.WouldReadSecret = canProceed && (config.SQLPasswordKey != "" || config.SQLCredFrom != "") + plan.WouldReadSecret = canProceed && (config.SQLPasswordKey != "" || config.SQLCredFrom != "" || (config.SQLUseSudo && config.SudoKey != "")) plan.WouldWriteLocalState = canProceed && config.SQLCredFrom != "" && config.SQLCredCacheTTL > 0 mutates := plan.SQL != nil && plan.SQL.Class != "" && plan.SQL.Class != string(sqlsafe.ClassRead) plan.WouldMutateRemote = plan.WouldExecute && mutates @@ -561,6 +566,7 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) { Statement: sqlsafe.RedactForAudit(config.SQLStatement), StatementHash: sqlStatementDigest(config.SQLStatement), Docker: config.SQLDockerContainer, + UseSudo: config.SQLUseSudo, } if config.SQLCredFrom != "" { sqlPlan.CredSource = config.SQLCredFrom @@ -646,6 +652,10 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) { sqlPlan.ExecuteCommand = conn.ExecuteCommand(cls.Statement).Command } } + if config.SQLUseSudo { + sqlPlan.ExplainCommand = sqlsafe.WrapSudoStdin(sqlPlan.ExplainCommand) + sqlPlan.ExecuteCommand = sqlsafe.WrapSudoStdin(sqlPlan.ExecuteCommand) + } } func fillDryRunApply(config *sshclient.Config, plan *dryRunPlan) { diff --git a/internal/app/mcp.go b/internal/app/mcp.go index fbe517c..fca0629 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -174,6 +174,7 @@ type mcpSQLInput struct { AllowFullTable bool `json:"allow_full_table,omitempty" jsonschema:"Required for UPDATE/DELETE without a WHERE clause."` NoBackup bool `json:"no_backup,omitempty" jsonschema:"Skip the pre-change backup; requires force."` BackupDir string `json:"backup_dir,omitempty" jsonschema:"Remote backup directory (default ~/.sshx/sql-backups)."` + Sudo bool `json:"sudo,omitempty" jsonschema:"Run the remote database client via sudo -S when the SSH user cannot open the database file."` Force bool `json:"force,omitempty" jsonschema:"Confirms DDL; destructive DDL also requires no_backup."` DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the guarded SQL plan without connecting."` TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."` @@ -339,6 +340,9 @@ func buildSQLArgs(in mcpSQLInput) ([]string, error) { if in.BackupDir != "" { args = append(args, "--backup-dir="+in.BackupDir) } + if in.Sudo { + args = append(args, "--sudo") + } if in.Force { args = append(args, "--force") } diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index 45fc553..95b271a 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -73,6 +73,7 @@ func TestBuildSQLArgs(t *testing.T) { DBPasswordKey: "app-db", Explain: true, RowThreshold: 500, + Sudo: true, DryRun: true, }) if err != nil { @@ -82,7 +83,7 @@ func TestBuildSQLArgs(t *testing.T) { "sql", "--json", "-h=db-1", "--engine=postgres", "--db=app", "--db-user=app", "--db-password-key=app-db", "--explain", "--row-threshold=500", - "--dry-run", + "--sudo", "--dry-run", "--", "SELECT count(*) FROM users", } assertArgs(t, args, want) diff --git a/internal/app/sql.go b/internal/app/sql.go index 6003380..2c880e9 100644 --- a/internal/app/sql.go +++ b/internal/app/sql.go @@ -49,6 +49,7 @@ type sqlJSONResult struct { AuthMethod string `json:"auth_method,omitempty"` CredSource string `json:"cred_source,omitempty"` CredCache string `json:"cred_cache,omitempty"` + Sudo bool `json:"sudo,omitempty"` ErrorKind string `json:"error_kind,omitempty"` Error string `json:"error,omitempty"` } @@ -115,6 +116,13 @@ func HandleSQL(config *sshclient.Config, audit *auditRecorder) (err error) { logger.GetLogger().Info("Note: Could not find host '%s' in settings, using as hostname directly", config.Host) } } + if config.SQLUseSudo { + password, pwdErr := sshclient.GetSudoPassword(config.SudoKey) + if pwdErr != nil { + return run.fail("secret", fmt.Errorf("resolve sudo password key %q: %w", config.SudoKey, pwdErr)) + } + config.SudoPassword = password + } if config.SQLPasswordKey != "" && sqlsafe.NormalizeEngine(config.SQLEngine) != sqlsafe.EngineSQLite { password, pwdErr := sshclient.GetSudoPassword(config.SQLPasswordKey) if pwdErr != nil { @@ -274,13 +282,23 @@ func validateSQLiteConfig(config *sshclient.Config) error { } // runRemote executes one assembled remote command, prepending the database -// password line to stdin when password delivery is enabled. +// password line to stdin when password delivery is enabled. --sudo wraps the +// whole command in sudo -S and prepends the sudo password so it never enters +// argv; sudo consumes the first line and the client sees the rest. func (r *sqlRun) runRemote(rc sqlsafe.RemoteCommand) (sshclient.ExecResult, error) { + command := rc.Command stdin := rc.Stdin if r.exec != nil && r.exec.NeedsPasswordLine() { stdin = r.password + "\n" + stdin } - return r.client.RunCommandWithInput(rc.Command, []byte(stdin)) + if r.config.SQLUseSudo { + if r.config.SudoPassword == "" { + return sshclient.ExecResult{ExitCode: -1}, fmt.Errorf("sql --sudo requires a resolved sudo password") + } + command = sqlsafe.WrapSudoStdin(command) + stdin = r.config.SudoPassword + "\n" + stdin + } + return r.client.RunCommandWithInput(command, []byte(stdin)) } // applyCredentials fills connection fields that the operator did not set @@ -318,7 +336,7 @@ func (r *sqlRun) resolveCredentials(source sqlsafe.CredSource) error { if cmdErr != nil { return r.fail("config", cmdErr) } - res, execErr := r.client.RunCommandWithInput(cmd, nil) + res, execErr := r.runRemote(sqlsafe.RemoteCommand{Command: cmd}) if execErr != nil || res.ExitCode != 0 { return r.fail("cred_source_failed", fmt.Errorf( "failed to read credential source %s (remote status %d)", @@ -546,6 +564,9 @@ func (r *sqlRun) failWithExit(kind string, res sshclient.ExecResult, failErr err safeErr := failErr if res.ExitCode >= 0 { safeErr = fmt.Errorf("database operation failed during %s with status %d", r.phase, res.ExitCode) + if detail := firstNonEmptyLine(res.Stderr, res.Stdout); detail != "" { + safeErr = fmt.Errorf("%s: %s", safeErr.Error(), redactSensitiveText(detail)) + } } r.recordAudit(res.ExitCode, kind, safeErr) if r.config.JSONOutput { @@ -553,6 +574,8 @@ func (r *sqlRun) failWithExit(kind string, res sshclient.ExecResult, failErr err result.ExitCode = res.ExitCode result.ErrorKind = kind result.Error = redactError(safeErr) + result.Stdout = res.Stdout + result.Stderr = res.Stderr emitSQLJSON(result) return ErrReported } @@ -565,6 +588,17 @@ func (r *sqlRun) failWithExit(kind string, res sshclient.ExecResult, failErr err return failErr } +func firstNonEmptyLine(chunks ...string) string { + for _, chunk := range chunks { + for _, line := range strings.Split(chunk, "\n") { + if s := strings.TrimSpace(line); s != "" { + return s + } + } + } + return "" +} + func (r *sqlRun) baseResult() sqlJSONResult { result := sqlJSONResult{ Host: r.config.Host, @@ -582,6 +616,7 @@ func (r *sqlRun) baseResult() sqlJSONResult { } result.CredSource = r.credSource result.CredCache = r.credCache + result.Sudo = r.config.SQLUseSudo if r.cls != nil { result.Class = string(r.cls.Class) result.Verb = r.cls.Verb diff --git a/internal/app/sql_test.go b/internal/app/sql_test.go index 99495c9..4f48451 100644 --- a/internal/app/sql_test.go +++ b/internal/app/sql_test.go @@ -1,11 +1,13 @@ package app import ( + "encoding/json" "strings" "testing" "time" "github.com/talkincode/sshx/internal/sqlsafe" + "github.com/talkincode/sshx/internal/sshclient" ) func TestParseArgs_SQLBasic(t *testing.T) { @@ -40,8 +42,11 @@ func TestParseArgs_SQLBasic(t *testing.T) { func TestParseArgs_SQLSQLite(t *testing.T) { config := ParseArgs([]string{ "sshx", "sql", "-h=app1", "--engine=SQLite3", "--db-file=/var/lib/app/app.db", - "--json", "SELECT 1", + "--sudo", "--json", "SELECT 1", }) + if !config.SQLUseSudo { + t.Fatal("expected --sudo to set SQLUseSudo") + } if config.SQLEngine != sqlsafe.EngineSQLite { t.Fatalf("expected sqlite engine, got %s", config.SQLEngine) } @@ -279,6 +284,9 @@ func TestFillDryRunSQL(t *testing.T) { if !strings.Contains(plan.SQL.ExecuteCommand, "sqlite3 -batch -bail /var/lib/app/app.db") { t.Fatalf("execute preview must use sqlite3: %s", plan.SQL.ExecuteCommand) } + if strings.Contains(plan.SQL.ExecuteCommand, "-readonly") { + t.Fatalf("sqlite DML must not use the non-portable -readonly flag: %s", plan.SQL.ExecuteCommand) + } if strings.Contains(plan.SQL.ExecuteCommand, "psql") { t.Fatal("sqlite plan must not assemble psql") } @@ -314,4 +322,70 @@ func TestFillDryRunSQL(t *testing.T) { t.Fatalf("read must not mutate: %#v", plan) } }) + t.Run("sqlite sudo wraps execute command", func(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/var/lib/app/app.db", + "--sudo", "--dry-run", "SELECT 1", + }) + plan := buildDryRunPlan(config) + if !plan.UsesSudo || plan.SQL == nil || !plan.SQL.UseSudo { + t.Fatalf("expected sudo in sql plan: %#v", plan) + } + if !plan.WouldReadSecret { + t.Fatal("sql --sudo must report would_read_secret") + } + if !strings.HasPrefix(plan.SQL.ExecuteCommand, "sudo -S -p '' sh -c ") { + t.Fatalf("execute preview must wrap sudo -S: %s", plan.SQL.ExecuteCommand) + } + if strings.Contains(plan.SQL.ExecuteCommand, "PGPASSWORD=") { + t.Fatal("sudo wrap must not embed secrets in argv") + } + }) + t.Run("sqlite read uses portable file uri", func(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/var/lib/app/app.db", + "--dry-run", "SELECT 1", + }) + plan := buildDryRunPlan(config) + if plan.SQL == nil || plan.SQL.Class != string(sqlsafe.ClassRead) { + t.Fatalf("expected sqlite read plan: %#v", plan.SQL) + } + if !strings.Contains(plan.SQL.ExecuteCommand, "file:/var/lib/app/app.db?mode=ro") { + t.Fatalf("sqlite read must open a mode=ro URI: %s", plan.SQL.ExecuteCommand) + } + if strings.Contains(plan.SQL.ExecuteCommand, "-readonly") { + t.Fatalf("sqlite read must not use the non-portable -readonly flag: %s", plan.SQL.ExecuteCommand) + } + }) +} + +func TestSQLJSONFailureIncludesRemoteStderr(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/tmp/x.db", "--json", "SELECT 1", + }) + run := &sqlRun{config: config, start: time.Now(), phase: "execute"} + + stdout := captureStdout(t, func() { + err := run.failWithExit("remote_exit", sshclient.ExecResult{ + ExitCode: 1, + Stderr: "sqlite3: Error: unknown option: -readonly\nUse -help for a list of options.\n", + }, nil) + if err != ErrReported { + t.Fatalf("expected ErrReported, got %v", err) + } + }) + + var result sqlJSONResult + if err := json.Unmarshal(stdout, &result); err != nil { + t.Fatalf("invalid JSON %q: %v", stdout, err) + } + if result.ErrorKind != "remote_exit" || result.ExitCode != 1 { + t.Fatalf("unexpected failure envelope: %#v", result) + } + if !strings.Contains(result.Error, "unknown option: -readonly") { + t.Fatalf("error should include remote stderr: %q", result.Error) + } + if !strings.Contains(result.Stderr, "unknown option: -readonly") { + t.Fatalf("JSON stderr missing remote client output: %q", result.Stderr) + } } diff --git a/internal/app/usage.go b/internal/app/usage.go index 0ea2963..e527d0b 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -269,6 +269,7 @@ Guarded SQL Execution: --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 @@ -297,6 +298,8 @@ Guarded SQL Execution: --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" Guarded File Apply: sshx apply -h= --path=/abs/remote.conf --from=./local.conf [options] diff --git a/internal/sqlsafe/postgres.go b/internal/sqlsafe/postgres.go index 234ccb8..1f8ee6f 100644 --- a/internal/sqlsafe/postgres.go +++ b/internal/sqlsafe/postgres.go @@ -364,6 +364,17 @@ func shellJoin(argv []string) string { return strings.Join(parts, " ") } +// WrapSudoStdin runs command under `sudo -S` with an empty prompt. The caller +// must prepend the sudo password plus a newline to stdin; sudo consumes that +// first line and the original command sees the rest. The password never enters +// argv. sh -c preserves pipelines, mkdir prefixes, and PGPASSWORD readers. +func WrapSudoStdin(command string) string { + if strings.TrimSpace(command) == "" { + return command + } + return "sudo -S -p '' sh -c " + shellQuote(command) +} + var safeArgRE = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) // ParseExplainRows extracts the top-level plan row estimate from diff --git a/internal/sqlsafe/sqlite.go b/internal/sqlsafe/sqlite.go index b860dfb..0de3165 100644 --- a/internal/sqlsafe/sqlite.go +++ b/internal/sqlsafe/sqlite.go @@ -69,7 +69,11 @@ func (c SQLiteConn) sqliteCommand(readOnly bool) (string, error) { } argv := []string{"sqlite3", "-batch", "-bail"} if readOnly { - argv = append(argv, "-readonly", "file:"+c.Path+"?mode=ro") + // Do not pass -readonly: that CLI flag is missing on sqlite 3.22 + // (Ubuntu 18.04 / several still-deployed distro packages). The + // file: URI mode=ro is honored by the sqlite3 shell without extra + // flags and still rejects writes. + argv = append(argv, "file:"+c.Path+"?mode=ro") } else { argv = append(argv, c.Path) } diff --git a/internal/sqlsafe/sqlite_test.go b/internal/sqlsafe/sqlite_test.go index b528e27..a9ce153 100644 --- a/internal/sqlsafe/sqlite_test.go +++ b/internal/sqlsafe/sqlite_test.go @@ -151,8 +151,10 @@ func TestSQLiteCommands(t *testing.T) { t.Run("read only uri", func(t *testing.T) { rc := conn.ExecuteReadCommand("SELECT 1") - assert.Contains(t, rc.Command, "sqlite3 -batch -bail -readonly") + assert.Contains(t, rc.Command, "sqlite3 -batch -bail") assert.Contains(t, rc.Command, "file:/var/lib/app/app.db?mode=ro") + assert.NotContains(t, rc.Command, "-readonly") + assert.NotContains(t, rc.Command, "-uri") assert.Equal(t, "SELECT 1;\n", rc.Stdin) assert.NotContains(t, rc.Command, "PGPASSWORD") }) @@ -208,6 +210,15 @@ func TestParseChangesOutput(t *testing.T) { assert.False(t, ok) } +func TestWrapSudoStdin(t *testing.T) { + wrapped := WrapSudoStdin("sqlite3 -batch -bail 'file:/var/lib/app.db?mode=ro'") + assert.True(t, strings.HasPrefix(wrapped, "sudo -S -p '' sh -c ")) + assert.Contains(t, wrapped, "file:/var/lib/app.db?mode=ro") + assert.NotContains(t, wrapped, "password") + assert.Equal(t, "", WrapSudoStdin("")) + assert.Equal(t, " ", WrapSudoStdin(" ")) +} + func TestNormalizeEngine(t *testing.T) { assert.Equal(t, EnginePostgres, NormalizeEngine("")) assert.Equal(t, EnginePostgres, NormalizeEngine("PostgreSQL")) @@ -252,3 +263,28 @@ func TestSQLiteBackupRoundTrip(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(data), "old") } + +func TestSQLiteReadCommandRunsWithoutReadonlyFlag(t *testing.T) { + if _, err := exec.LookPath("sqlite3"); err != nil { + t.Skip("sqlite3 not installed") + } + dir := t.TempDir() + db := filepath.Join(dir, "app.db") + setup := exec.Command("sqlite3", db, "CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1);") // #nosec G204 -- fixed test fixture + require.NoError(t, setup.Run()) + + rc := SQLiteConn{Path: db}.ExecuteReadCommand("SELECT id FROM t") + require.NotContains(t, rc.Command, "-readonly") + + cmd := exec.Command("sh", "-c", rc.Command) // #nosec G204 -- command is generated from validated fixture paths + cmd.Stdin = strings.NewReader(rc.Stdin) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + assert.Equal(t, "1\n", string(out)) + + write := exec.Command("sh", "-c", rc.Command) // #nosec G204 -- same generated read-only command + write.Stdin = strings.NewReader("INSERT INTO t VALUES (2);\n") + writeOut, writeErr := write.CombinedOutput() + require.Error(t, writeErr, string(writeOut)) + assert.Contains(t, strings.ToLower(string(writeOut)), "readonly") +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 482dd99..dcb49ef 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -195,6 +195,10 @@ type Config struct { SQLCredCacheTTL time.Duration // SQLCredRefresh forces re-resolution, replacing any cached entry. SQLCredRefresh bool + // SQLUseSudo runs the remote database client via sudo -S. Use it when the + // SSH user cannot read or write the database file (typical for service- + // owned SQLite). The sudo password is delivered on stdin ahead of SQL. + SQLUseSudo bool // Guarded file apply fields (Mode == "apply"). ApplyExpectSHA256 string diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index f0ce3dd..7c70214 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -324,6 +324,9 @@ sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --dry-run --json \ 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" ``` - Reads open `file:?mode=ro`. DML does not use EXPLAIN row estimates: @@ -337,6 +340,9 @@ sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \ already covers related tables, so that check is skipped. - `--db-user`, `--db-password-key`, `--docker`, and `--db-cred-from` are rejected. `--db=` may be used as an alias for the absolute file path. +- `--sudo` runs the remote client via `sudo -S` (empty prompt). Use it when + the SSH user cannot read or write the database file. The host sudo key is + delivered on stdin ahead of the SQL — never in argv. JSON reports `sudo`. ## Guarded file apply @@ -461,7 +467,8 @@ sshx --help # full reference through `sshx run`: it classifies the statement, backs up affected data, and audits everything. Preview with `--dry-run --json` first. PostgreSQL adds `--docker=` / `--db-cred-from=` for containerized DBs; - SQLite uses `--engine=sqlite --db-file=/abs/path.db`. Direct `psql` / + SQLite uses `--engine=sqlite --db-file=/abs/path.db`. Add `--sudo` when + the SSH user cannot open the database file. Direct `psql` / `sqlite3` invocations are blocked — rework as `sshx sql`, do not `--force`. 7. For remote file edits use `sshx apply`, never `sed -i` or upload-then-`install`. Preview with `--dry-run --json`. Branch on `changed` and `error_kind`