Repository navigation
Description
Summary
Follow-up from the 2026-09-20 read-only session-usage analysis (report rpt_1c1aac72-4f8f-41e1-9db4-1a8a1c05f8a4, evidence session mira-7ea9943d). It confirmed three real defects in sshx v0.17.0 and flagged four older experience-log entries that are already fixed in code but still marked open.
All three defects are addressed on one branch; this issue carries the evidence and the verification status. Security semantics are untouched: the SQL guard still blocks a direct psql, and --json stdout keeps exactly one document.
D1 — P1: a policy block looks silent to a caller that only prints stdout/stderr
docker exec … psql … is blocked at admission. The reason is complete in the JSON, but a caller that prints only exit_code / stdout / stderr sees exit_code=-1 with two empty streams and reads it as a silent refusal (5 occurrences in the reporting session).
sshx -h=teamsacs-v3 --json "docker exec teamsacs_pgdb18 psql -U teamsacs -d teamsacs -At -c 'select 1'"Before (v0.17.0) / after (branch), same command, streams captured separately:
| build | rc | stdout | stderr | JSON |
|---|---|---|---|---|
| v0.17.0 | 255 | 1228 B | 0 B | exit_code=-1, error_kind=blocked, phase=admission, executed=false |
| branch | 255 | 1228 B | 502 B | identical fields |
After, stderr carries the decision plus the flattened reason (stdout is unchanged, one JSON document):
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 teamsacs_pgdb18 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
Work: stderr mirror in --json mode; the block predicate (exit_code=-1 + error_kind=blocked + phase=admission + executed=false) and the sshx sql --docker= alternative documented in README.md / README_CN.md / usage.go; the reason text (including the sshx sql --docker= suggestion) pinned by unit and E2E assertions.
D2 — P1: sshx text large-file scans are slow and print nothing
time sshx text -h=teamsacs-v3 --path=/data/appdata/teamsacs_data/logs/teamsacs.log \
--pattern='mqtt.*(connect|connected|reconnect)' --tail=2000 --json | jq '.stats'The 5.15 MB file took 52 s in the original report (90.4 s / 83.0 s median on the same host re-measured here) with zero output, so callers timing out at 30 s give up. Root cause: internal/sshclient/text.go built sftp.NewClient(c.client) without pipelined reads — pkg/sftp v1.13.10 reads 32 KiB sequentially unless the caller offers larger buffers, which makes the scan one RTT per 32 KiB.
Fix: a pipelined read path (read-ahead aperture + UseConcurrentReads(true) + MaxConcurrentRequestsPerFile), keeping --max-scan-bytes as the bound for both the scan and the read-ahead.
Alternating A/B runs on the identical command, pristine 390caff build vs branch build:
| round | baseline | branch |
|---|---|---|
| 1 | 88.0 s | 23.8 s |
| 2 | 77.9 s | 29.1 s |
| median | 83.0 s | 26.5 s |
Identical work in every run: bytes=8388608, ~46 980 lines, truncated_reason=max_scan_bytes. That is 3.1×, and the remaining cost is the link itself (~800 KB/s exec throughput, ~1 s per 32 KiB request, 39 ms RTT), not the client.
Also fixed: a long scan narrates progress on stderr (bytes, percentage, lines, elapsed, matches) after a short grace period, and a budget-limited scan says so and names --offset / --tail / --pattern / --max-scan-bytes. sshx.text.v1 gains an additive stats.expected_scan_bytes.
D3 — P2: sudo password auto-fill only applies to the first token
cd /data/… && sudo docker compose … fails with sudo: a password is required (29 occurrences in the reporting session). internal/sshclient/validate.go's leadingSudoRemainder only matches sudo at the start of the command; the boundary is real and AGENT.md §7 states it, but nothing told the caller at fault time.
Work: sshx now warns on stderr when a non-leading sudo is present — once before connecting, and again when the remote actually reports a password prompt — and suggests wrapping the privileged part:
sshx -h=prod-web "sudo sh -c 'cd /data/app && docker compose up -d'"Auto-fill scope itself is unchanged (never silently widened). The documented boundary is in README.md / README_CN.md / usage.go. The multi-sudo-in-one-chain case overlaps #78, which stays open for the separate run --target= key-resolution defect (repro A there).
D4 — P2: four experience-log entries are already fixed, still marked open
Source: Tools/sshx-experience-log.md (ram-ta3). Verified against current main — please archive these as fixed and backfill this issue's link:
| Entry | Status | Evidence |
|---|---|---|
--script-file forced sh |
fixed | shebang is honoured, --shell= overrides it (internal/app/run.go:432-447); an unsupported shebang is reported, not silently downgraded |
sql --docker defaulted to role postgres |
fixed | credentials are read from the container's env (POSTGRES_USER / POSTGRES_DB …, internal/sqlsafe/credsource.go:94-96); live run reports cred_source=docker:teamsacs_pgdb18 |
sql "eats" string/integer literals |
fixed, with a nuance | SELECT 'a'||'b' AS x, 42 AS n executes verbatim and returns ab | 42. The echoed statement field is audit-redacted (SELECT || AS x, ? AS n) by design, which is what made it look like rewriting — the result rows are correct |
--bind source address |
fixed | --bind=<addr|iface> plus the per-host bind field (internal/app/config.go:46-49) |
Constraints honoured
- The SQL guard was not weakened: the reproduction above still exits 255 with
error_kind=blockedandexecuted=false, and a directpsqlremains blocked; the credential/SQL route is the only sanctioned path. --jsonstdout purity: the mirror and the progress lines are stderr-only, and the blocked JSON payload is unchanged apart from run identity and timestamps.- No credentials or secrets are added to the repo;
-pk=/ key names only.
Verification
- New tests:
internal/sshclient/sftp_read_test.go,internal/sshclient/validate_sudo_test.go,internal/app/lifecycle_policy_test.go,internal/app/text_progress_test.go,internal/textsafe/scan_progress_test.go,tests/e2e/observability_e2e_test.go. gofmt -l internal/ tests/clean,go vet ./...clean,go test ./...green except the pre-existingTestApplySudoScriptEvidenceAndCleanuppanic ininternal/sshclient, which also fails at pristine390caff.- Docs:
README.md,README_CN.md,internal/app/usage.go,internal/textsafe/help.go,docs/text.md,AGENT.md,CHANGELOG.md.
Activity
mira-voss commentedon Sep 22, 2026
Implemented in #81 (5 commits on
fix/session-analysis-followups, not merged).Final read-only verification on the reporting host, with the branch build:
One correction worth recording: the byte-budget warning first compared
truncated_reasontomax_scan_bytesexactly, and the real host returns the joined formmax_scan_bytes,max_hits, so the warning stayed silent — the unit tests passed because they used a single reason. It now tests membership (textsafe.HasTruncationReason), and both a unit and an E2E test use a joined reason.D4 (
Tools/sshx-experience-log.mdbackfill) is not touched by the PR: that file lives in the ram-ta3 checkout, which this task does not hold. The links to backfill are in the archive table above.