Skip to content

Session-analysis follow-ups: policy-block visibility, sshx text SFTP read pipelining, non-leading sudo boundary #80

Closed

Description

@mira-voss

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=blocked and executed=false, and a direct psql remains blocked; the credential/SQL route is the only sanctioned path.
  • --json stdout 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-existing TestApplySudoScriptEvidenceAndCleanup panic in internal/sshclient, which also fails at pristine 390caff.
  • Docs: README.md, README_CN.md, internal/app/usage.go, internal/textsafe/help.go, docs/text.md, AGENT.md, CHANGELOG.md.

Activity

  1. added
    bugSomething isn't working
    taskActionable implementation task
    on Sep 22, 2026
  2. mira-voss commented on Sep 22, 2026

    @mira-voss
    ContributorAuthor

    Implemented in #81 (5 commits on fix/session-analysis-followups, not merged).

    Final read-only verification on the reporting host, with the branch build:

    # D2 — a budget-limited scan now warns; stdout stays one sshx.text.v1 document
    $ sshx text -h=teamsacs-v3 --path=…/teamsacs.log --pattern='mqtt' --scan=start \
        --max-scan-bytes=262144 --json
    sshx text: warning: scan stopped at its --max-scan-bytes budget after 2.3s (256.0KiB/262144B scanned, 256.0KiB/262144B 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.
    {"truncated":true,"truncated_reason":"max_scan_bytes,max_hits","bytes":262144,"expected":262144,"total_hits_exact":false}
    
    # D1 — the block keeps its payload and gains the reason on stderr
    $ sshx -h=teamsacs-v3 --json "docker exec teamsacs_pgdb18 psql -U teamsacs -d teamsacs -At -c 'select 1'"
    rc=255  stdout=1228B  stderr=502B
    {"exit_code":-1,"error_kind":"blocked","phase":"admission","executed":false}
    

    One correction worth recording: the byte-budget warning first compared truncated_reason to max_scan_bytes exactly, and the real host returns the joined form max_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.md backfill) 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtaskActionable implementation task

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions