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
10 changes: 5 additions & 5 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ linters:
misspell:
locale: US

issues:
exclude-rules:
- text: "misspelling of"
linters:
- misspell
exclusions:
rules:
- text: "misspelling of"
linters:
- misspell
4 changes: 3 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,9 @@ tests.
4. **Sudo auto-fill only supports leading `sudo`.** `CommandUsesSudo` returns
true only when the remote command starts with `sudo`, matching the exact
form `sudoStdinCommand` can safely rewrite. Non-leading sudo inside shell
wrappers or pipelines is left untouched.
wrappers or pipelines is left untouched and is never silently ignored: sshx
warns on stderr before connecting and again when the remote reports
`sudo: a password is required`, suggesting `sudo sh -c "<command>"`.
5. **Command safety checks.** Destructive operations (`rm -rf /`, `mkfs`, `dd`,
fork bombs, `curl | sh`, critical file edits, shutdown/reboot) are blocked
unless `--force`/`-f` or `--no-safety-check` is given. Matching happens on
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ 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
(bytes, percentage, lines, elapsed, matches) and warns when a scan stops at
its `--max-scan-bytes` budget, naming `--offset`/`--tail`/`--pattern`/
`--max-scan-bytes` as the ways to narrow it. A slow scan closes with the same
advice. stdout stays exactly one JSON document; progress never enters it.
- `sshx.text.v1` `stats.expected_scan_bytes` (additive): the byte budget of the
scanned window, so a caller can size a scan and spot a partial one before
trusting `total_hits_exact`. Omitted when the source size is unknown (journal).
- A policy block now mirrors its reason to stderr in `--json` mode
(`sshx: blocked by safety policy (phase=admission, error_kind=blocked,
executed=false, exit_code=-1)`) plus the flattened reason with the guarded
alternative, so a caller that only prints stdout/stderr no longer sees a
silent refusal. The block predicate and the `sshx sql --docker=` alternative
are documented in README/README_CN/usage, with unit and E2E assertions.
- `sshx` warns when a command contains a non-leading `sudo` (for example
`cd /data/app && sudo docker compose up -d`): password auto-fill only rewrites
a leading `sudo`, so it announces the boundary before connecting and explains
the refusal afterwards, suggesting `sudo sh -c "<command>"`. The auto-fill
scope is unchanged.

### Fixed

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

## [0.17.0] - 2026-09-16

### Added
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ It remains a single binary with one-shot invocations and no resident component o
14. Session-bound jump hosts (`via` / `--via=`): nested SSH to private targets
with per-hop trust and audit, without local tunnels.
15. `sshx text`: bounded remote log/text dissection (exception blocks, presets,
line windows) over SFTP or sshx-owned journalctl.
line windows) over SFTP or sshx-owned journalctl, with pipelined reads and
stderr scan progress so a wide window is never mistaken for a hang.

## Installation

Expand Down Expand Up @@ -260,6 +261,22 @@ By default:
| `1..254` | Remote command's exit status, propagated verbatim |
| `255` | `sshx`-level failure (connect / auth / host-key / timeout / blocked) |

A policy block reaches stderr as well as stdout, so a caller that only prints
the streams sees the reason instead of a silent refusal:

```text
$ sshx -h=prod-db --json "docker exec pg psql -U app -d app -c 'select 1'"
sshx: blocked by safety policy (phase=admission, error_kind=blocked, executed=false, exit_code=-1); no remote command ran
sshx: block reason: ⚠️ Dangerous command blocked | Command: docker exec pg psql … | Reason: Direct PostgreSQL client execution ("psql") bypasses the guarded SQL pipeline. Use: sshx sql -h=<host> --db=<name> [--docker=<container>] "<SQL>" (adds classification, backups, and audit) | If you are sure, use --force or -f flag
```

The block predicate is machine-readable, and all four fields appear together:
`exit_code=-1`, `error_kind=blocked`, `phase=admission`, `executed=false`. A
blocked command never reaches the network. Use the guarding path instead:
`sshx sql -h=<host> --db=<name> [--docker=<container>] "<SQL>"`. stdout still
carries exactly one JSON document; the mirror lines are additional, not a
replacement.

### `--json` structured output

Add `--json` to get a single JSON object on stdout (diagnostics still go to
Expand Down Expand Up @@ -786,6 +803,23 @@ sshx -h=192.168.1.101 -pk=server-B "sudo systemctl restart nginx"
sshx -h=192.168.1.102 -pk=server-C "sudo systemctl restart nginx"
```

**Auto-fill only rewrites the first token.** sshx detects a password prompt and
feeds the stored password only when `sudo` is the command's leading token. A
`sudo` that appears later runs without a password:

```bash
# NOT filled — sudo is not the leading token; the remote fails with
# "sudo: a password is required"
sshx -h=prod-web "cd /data/app && sudo docker compose up -d"

# Filled — wrap the whole privileged command
sshx -h=prod-web "sudo sh -c 'cd /data/app && docker compose up -d'"
```

sshx warns on stderr when it sees the first form, both before connecting and
again if the remote reports that sudo wanted a password. Auto-fill never
changes the command text in ways the caller did not ask for.

## Host Key Verification 🔐

`sshx` now enforces strict host key verification just like the OpenSSH client. Instead of silently trusting unknown hosts, the tool reads the trust store from `~/.ssh/known_hosts` (or the path you provide) and aborts the connection if the host is missing or the key changes.
Expand Down
24 changes: 23 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组
12. 人类专用的 `sshx login`:登录已配置主机,可选 `--sudo` 进入特权壳。不属于 Agent/MCP 契约。
13. 源地址绑定(`--bind=<ip|iface>`),对齐 OpenSSH `-b` / `BindAddress` / `BindInterface`。
14. 会话内跳板(`via` / `--via=`):嵌套 SSH 进入私网目标,每跳独立信任与审计,不在本机开隧道。
15. `sshx text`:有界远端日志/文本解剖(异常块、preset、行窗),经 SFTP 或 sshx 持有的 journalctl。
15. `sshx text`:有界远端日志/文本解剖(异常块、preset、行窗),经 SFTP 或 sshx 持有的 journalctl;读取走并发流水线,扫描进度写 stderr,宽窗口不再像卡死。

## 安装

Expand Down Expand Up @@ -259,6 +259,16 @@ sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.lo
| `1..254` | 远程命令的退出码,原样透传 |
| `255` | `sshx` 层面的失败(连接 / 认证 / 主机密钥 / 超时 / 被拦截) |

被安全策略拦截时,除 stdout 的 JSON 外,stderr 也会给出人类可读的原因,只打印两个流的调用方不会把拦截误读成「静默拒绝」:

```text
$ sshx -h=prod-db --json "docker exec pg exec -U app -d app -c 'select 1'"
sshx: blocked by safety policy (phase=admission, error_kind=blocked, executed=false, exit_code=-1); no remote command ran
sshx: block reason: ⚠️ Dangerous command blocked | ... | Reason: Direct PostgreSQL client execution ("psql") bypasses the guarded SQL pipeline. Use: sshx sql -h=<host> --db=<name> [--docker=<container>] "<SQL>" ...
```

拦截判定是机器可读的,四个字段同时出现:`exit_code=-1`、`error_kind=blocked`、`phase=admission`、`executed=false`。被拦截的命令不会触网。请改用受管路径:`sshx sql -h=<host> --db=<name> [--docker=<container>] "<SQL>"`。stdout 仍只有一份 JSON 文档,stderr 的两行是补充信息。

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

加上 `--json` 即可在 stdout 得到单个 JSON 对象(诊断日志仍走 stderr,保证 stdout 纯净):
Expand Down Expand Up @@ -570,6 +580,18 @@ sshx -h=192.168.1.101 -pk=server-B "sudo systemctl restart nginx"
sshx -h=192.168.1.102 -pk=server-C "sudo systemctl restart nginx"
```

**自动填密只作用于命令的首个 token。** 只有命令以 `sudo` 开头时,sshx 才会识别密码提示并注入已保存的密码;出现在 `&&` 之后、脚本内部或管道中的 `sudo` 不会被填密:

```bash
# 不会填密 —— sudo 不是首个 token,远端会以 "sudo: a password is required" 失败
sshx -h=prod-web "cd /data/app && sudo docker compose up -d"

# 会填密 —— 把整条特权命令包起来
sshx -h=prod-web "sudo sh -c 'cd /data/app && docker compose up -d'"
```

sshx 遇到前一种写法会在 stderr 给出提示:连接前给一次,远端确实报出 sudo 索要密码时再给一次。填密范围本身没有变化。

### 密码键名说明

- **master**: 默认的 sudo 密码键名,用于 sudo 命令
Expand Down
36 changes: 36 additions & 0 deletions docs/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,39 @@ JSON schema `sshx.text.v1`. Branch on `success`, `hits[].kind`,
`stats.total_hits` vs `stats.returned`, `truncated`, `truncated_reason`,
`redacted`, and `line_origin` (`file` or `scanned_window`). Secret-shaped
spans are redacted unless `--no-redact`. Audit events omit hit text.

`stats` also carries the window accounting a caller needs to judge coverage:

| Field | Meaning |
| --------------------- | ---------------------------------------------------------------- |
| `file_size` | Remote file size when the source is a file (omitted for journal) |
| `window_start_byte` | First byte the window reads |
| `expected_scan_bytes` | Window budget: the window capped by `--max-scan-bytes` |

`expected_scan_bytes` is additive and omitted when the source size is unknown
(journal). Compare it with `bytes_scanned`: a scan that stopped early reports
less than it expected, and a scan that hit its budget sets
`truncated_reason=max_scan_bytes` with `total_hits_exact=false`.

## Monitoring

A wide window over a slow link used to look like a hang. `sshx text` now
narrates progress on **stderr** while it scans:

```text
sshx text: scanning 2.0MiB/2097152B of 61.3MiB/64296766B (3%) lines=10240 elapsed=4.1s matches=0
sshx text: warning: scan stopped at its --max-scan-bytes budget after 12.0s (8.0MiB/8388608B scanned, 8.0MiB/8388608B in window); results are partial (total_hits_exact=false). Narrow with --offset=N or --tail=N, pre-filter with --pattern=..., or raise --max-scan-bytes=N.
```

- Progress appears only after a grace period, at most once per second, and
never for a scan that finishes quickly.
- A scan that stops at its byte budget always warns, so partial results are
never mistaken for complete ones. A slow scan closes with the same
`--offset`/`--tail` advice.
- **stdout stays exactly one JSON document.** Progress and advice go to stderr
only, so `--json` parsing is unaffected.

```bash
# Narrow the window instead of scanning 8MiB to answer a small question
sshx text -h=prod-web --path=/var/log/app.log --offset=41000 --limit=200 --json
```
19 changes: 19 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/signal"
Expand Down Expand Up @@ -339,6 +340,10 @@ func RunContext(ctx context.Context, args []string) (err error) {
config.SudoPassword = password
logger.GetLogger().Success("Sudo password will be auto-filled when prompted")
}
} else if hint, needed := sshclient.NonLeadingSudoHint(config.Command); needed && config.SudoKey != "" {
// Warn before connecting: this command will prompt for a password that
// no non-interactive session can answer.
logger.GetLogger().Warning("sudo password auto-fill skipped for this command: %s", hint)
}

// Create SSH client
Expand Down Expand Up @@ -377,6 +382,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)

if config.JSONOutput {
if outputErr := emitCommandJSON(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr); outputErr != nil {
Expand Down Expand Up @@ -428,6 +434,19 @@ func reportSSHFailure(config *sshclient.Config, audit *auditRecorder, authMethod
return err
}

// reportSudoPromptFailure explains a sudo refusal sshx cannot help with: the
// stored password is only injected for a leading sudo, so a mid-command sudo
// stops with "a password is required". It writes straight to stderr rather than
// through the logger, because it explains a failed run to a caller that may be
// running with diagnostics quieted.
func reportSudoPromptFailure(w io.Writer, command, output string) {
hint, needed := sshclient.NonLeadingSudoHint(command)
if !needed || !sshclient.SudoPasswordPromptFailure(output) {
return
}
writeDiagnosticNote(w, "sshx: %s\n", hint)
}

// emitCommandJSON writes a single JSON result line to stdout. Diagnostic logs go
// to stderr, so stdout stays a pure machine-readable stream.
func emitCommandJSON(config *sshclient.Config, authMethod sshclient.AuthMethod, res sshclient.ExecResult, dur time.Duration, errKind string, execErr error) error {
Expand Down
19 changes: 19 additions & 0 deletions internal/app/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package app

import (
"fmt"
"io"

"github.com/talkincode/sshx/pkg/logger"
)

// writeDiagnosticNote writes a best-effort diagnostic note to w.
//
// Notes explain a result that has already been reported on stdout, so a failed
// write must never change the command's outcome and has nowhere better to go
// than the debug log (which is itself stderr-only, keeping --json stdout pure).
func writeDiagnosticNote(w io.Writer, format string, args ...any) {
if _, err := fmt.Fprintf(w, format, args...); err != nil {
logger.GetLogger().Debug("failed to write diagnostic note: %v", err)
}
}
61 changes: 56 additions & 5 deletions internal/app/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/talkincode/sshx/internal/execution"
Expand Down Expand Up @@ -111,12 +114,64 @@ func emitLifecycleJSON(config *sshclient.Config, value any) error {
if err != nil {
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)
if err := encodeJSON(document); err != nil {
return fmt.Errorf("%w: deliver execution result: %w", execution.ErrLocalIO, err)
}
return nil
}

// reportPolicyRejection mirrors a blocked admission decision to stderr. The
// rejection predicate is machine-readable (exit_code=-1, error_kind=blocked,
// phase=admission, executed=false) but lives inside the stdout document, which
// callers that surface only stdout/stderr used to read as a silent refusal.
// stdout keeps exactly one JSON document, so --json stays machine-parsable.
func reportPolicyRejection(w io.Writer, document map[string]json.RawMessage) {
if documentString(document, "error_kind") != execution.ErrorKindBlocked {
return
}
phase := documentString(document, "phase")
if phase == "" {
phase = execution.PhaseAdmission
}
executed := strings.TrimSpace(string(document["executed"]))
if executed != "true" {
executed = "false"
}
exitCode := -1
if raw, ok := document["exit_code"]; ok {
_ = json.Unmarshal(raw, &exitCode) //nolint:errcheck // only reads the optional numeric projection
}
writeDiagnosticNote(w, "sshx: blocked by safety policy (phase=%s, error_kind=%s, executed=%s, exit_code=%d); no remote command ran\n",
phase, execution.ErrorKindBlocked, executed, exitCode)
if reason := flattenPolicyReason(documentString(document, "error")); reason != "" {
writeDiagnosticNote(w, "sshx: block reason: %s\n", reason)
}
}

// flattenPolicyReason collapses a multi-line policy message into one greppable
// line.
func flattenPolicyReason(message string) string {
parts := strings.Split(message, "\n")
kept := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
kept = append(kept, trimmed)
}
}
return strings.Join(kept, " | ")
}

func documentString(document map[string]json.RawMessage, key string) string {
var text string
if raw, ok := document[key]; ok {
_ = json.Unmarshal(raw, &text) //nolint:errcheck // only reads optional string projections
}
return text
}

func finalizeLifecycle(config *sshclient.Config, value any) (map[string]json.RawMessage, error) {
data, err := marshalLifecycleValue(value)
if err != nil {
Expand Down Expand Up @@ -158,11 +213,7 @@ func finalizeLifecycle(config *sshclient.Config, value any) (map[string]json.Raw
}
}
stringField := func(key string) string {
var text string
if raw, ok := document[key]; ok {
_ = json.Unmarshal(raw, &text) //nolint:errcheck // only reads optional string projections
}
return text
return documentString(document, key)
}
code := -1
if raw, ok := document["exit_code"]; ok {
Expand Down
Loading
Loading