Skip to content

fix: policy-block visibility, sshx text read pipelining, and the non-leading sudo boundary - #81

Merged
jamiesun merged 7 commits into
mainfrom
fix/session-analysis-followups
Sep 23, 2026
Merged

jamiesun merged 7 commits into
mainfrom
fix/session-analysis-followups

Conversation

@mira-voss

@mira-voss mira-voss commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Addresses the three defects confirmed by the read-only session-usage analysis of 2026-09-20 (evidence in #80), plus the documentation that kept the sudo boundary invisible.

Security semantics are unchanged: a direct psql is still blocked, and --json stdout still carries exactly one document.

What changed

sshx text was slow and silent (P1). A line scanner asks the SFTP layer for a few kilobytes, and pkg/sftp only overlaps round trips when handed a buffer larger than its packet size — so every read cost a full round trip. Scans now read through a 1 MiB read-ahead aperture with UseConcurrentReads and a bounded in-flight count, and SFTP sessions come from newSFTPClient (same connection, boundary-typed errors).

Same command, same host, alternating rounds, pristine 390caff build vs this branch:

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). The remaining cost is the link (~800 KB/s, ~1 s per 32 KiB request), not the client. --max-scan-bytes still bounds what a scan pulls: the same budget bounds the aperture, and the one-byte truncation probe bypasses it.

A long scan looked like a hang (P1). sshx text now narrates progress on stderr after a 3 s grace period (bytes, percentage, lines, elapsed, matches), warns when a scan stops at its byte budget and names the ways to narrow it, and closes a slow scan with the same advice. sshx.text.v1 gains the additive stats.expected_scan_bytes. stdout keeps exactly one JSON document.

A policy block looked like a silent refusal (P1). --json mode now mirrors the block decision and the flattened reason to stderr, so a caller that prints only the streams sees why nothing ran:

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 | … Use: sshx sql -h=<host> --db=<name> [--docker=<container>] "<SQL>" …
build rc stdout stderr
v0.17.0 255 1228 B 0 B
branch 255 1228 B 502 B

Non-leading sudo (P2). Auto-fill still rewrites a leading sudo only, but the boundary is now announced: sshx warns before connecting when a command runs sudo in command position after a segment or wrapper, and explains the refusal when the remote reports a password prompt — suggesting sudo sh -c "<command>". cd /data/app && sudo docker compose up -d failed 29 times in the reporting session with no explanation.

Files

Area Files
Read pipelining internal/sshclient/sftp_read.go, text.go, transport.go, remote_state.go
Monitoring internal/app/text_progress.go, internal/app/text.go, internal/textsafe/{scan,types,help}.go, docs/text.md
Boundaries internal/app/lifecycle.go, internal/app/app.go, internal/app/run.go, internal/sshclient/validate*.go
Docs README.md, README_CN.md, internal/app/usage.go, AGENT.md, CHANGELOG.md

Bugfix reflection

Bug class:
  1. RTT-bound remote reads hidden behind a streaming API (per-read round trips
     when the caller's buffer is smaller than a protocol packet).
  2. A decision reported only on a machine-readable channel, invisible to a
     caller that reads the process streams.
  3. A documented capability boundary that fails loudly at the remote end and
     silently at the local end.

Missing invariant:
  1. Read latency must scale with the window, not with the request count.
  2. Every refusal must be visible on stderr, and every partial result must say
     it is partial.
  3. When sshx declines to rewrite a command, it must say so at the moment the
     command is submitted. (Documented in AGENT.md section 7.4 before this PR,
     enforced nowhere.)

Why existing tests missed it:
  1. Unit tests read from in-memory readers, and E2E ran against a local server
     where a round trip is free, so read count never mattered.
  2. Tests asserted the JSON payload, which was always correct; nobody asserted
     the process streams a shell caller actually sees.
  3. The pre-connect sudo notice is a diagnostic log, and the e2e harness pins
     the log level to error, so the failure-time hint had to bypass the logger
     to be visible at all.

Regression test added:
  tests/e2e/observability_e2e_test.go (scan completeness over a window larger
  than one packet, joined-reason truncation warning with stdout purity, quiet
  fast scan, blocked-command mirror with stdio untouched, pre-connect and
  remote-refusal sudo hints), internal/app/lifecycle_policy_test.go,
  internal/app/text_progress_test.go, internal/textsafe/scan_progress_test.go,
  internal/sshclient/sftp_read_test.go, internal/sshclient/validate_sudo_test.go.

Docs or AGENT.md update needed:
  README.md, README_CN.md, internal/app/usage.go, internal/textsafe/help.go,
  docs/text.md, AGENT.md section 7.4, CHANGELOG.md.

Verification run:
  gofmt -l internal/ tests/ cmd/ pkg/   (clean)
  go vet ./...                          (clean)
  go test ./...                         (green except the pre-existing
    TestApplySudoScriptEvidenceAndCleanup panic in internal/sshclient, which
    also fails at pristine 390caff)
  go test -race ./internal/{sshclient,textsafe,app}/ (green)
  Live on the reporting host, read-only: a truncated scan over 256 KiB warns on
  stderr in 2.3 s with truncated_reason=max_scan_bytes,max_hits while stdout
  stays one document; the blocked psql reproduction keeps its 1228 B payload
  and adds 502 B of stderr.

Lint gate

The CI lint job pins golangci-lint version: latest, which now resolves to 2.13.2 and
rejects the retired top-level issues.exclude-rules key, so config verify aborts before
a single file is linted. That job is red on main for the same reason. Two commits deal
with it:

  • chore(ci): the rule moves to its v2 home, linters.exclusions.rules. Rule and effect
    are unchanged — config verify passes, and the migrated config still reports 0 issues
    on main and the 6 errcheck findings below. Drop this commit if the config migration
    should land separately, but the lint job cannot run without it.
  • fix(app): running the linter locally at CI's version surfaced 6 unchecked
    fmt.Fprintf/Fprintln diagnostic writes in this branch's new code — the CI job never
    reached them because verification failed first. They now go through one best-effort
    helper that checks the write error and records a failure at debug level, so a failed
    diagnostic write cannot change a command outcome and --json stdout stays pure.

Local gate at golangci-lint 2.13.2 with the repository config:

golangci-lint config verify                          (passes)
golangci-lint run --max-same-issues=0 \
                  --max-issues-per-linter=0 ./...    0 issues

Worth considering: pin the action's version instead of latest so a linter release
cannot switch the gate off again without a code change.

Notes

mira-voss and others added 5 commits September 22, 2026 22:56
A line scanner asks the SFTP layer for a few kilobytes at a time, and
pkg/sftp only overlaps round trips when it is handed a buffer larger than
its packet size. Every read therefore cost a full round trip: an 8 MiB
window over a ~40 ms link spent 83 s median in read latency while the
remote transmitted ~800 KB/s.

Read through a 1 MiB read-ahead aperture with UseConcurrentReads and a
bounded in-flight request count. The aperture is bounded by the caller's
byte budget, so --max-scan-bytes still caps what a scan pulls from the
remote file, and reads smaller than a packet (the truncation probe) still
go through untouched. SFTP sessions now come from newSFTPClient, which
reuses the same connection with boundary-typed errors.

Median for the reported command on the reporting host drops from 83.0 s
to 26.5 s (88.0/77.9 s -> 23.8/29.1 s, alternating rounds) with identical
bytes and lines scanned.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A wide window over a slow link produced no output for a minute or more, so
callers timing out at 30 s could not tell a slow scan from a hang, and a
scan that stopped at its byte budget returned partial results without
saying so.

Narrate progress on stderr after a 3 s grace period, at most once per
second, with bytes, percentage, lines, elapsed and matches. A scan that
stops at --max-scan-bytes warns that its results are partial and names
--offset/--tail/--pattern/--max-scan-bytes as the ways to narrow it; a
slow scan closes with the same advice. Truncation reasons are a joined
list ("max_scan_bytes,max_hits"), so the warning tests membership instead
of string equality.

stats gains expected_scan_bytes, the byte budget of the window, so a
caller can size a scan and spot a partial one before trusting
total_hits_exact. The field is additive and omitted for journal scans.

stdout still carries exactly one sshx.text.v1 document: progress and
advice are stderr-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A policy block carries its reason in the --json document, so a caller that
prints only exit_code/stdout/stderr read "exit_code=-1" with two empty
streams and concluded the refusal was silent.

Mirror the decision and the flattened reason to stderr in --json mode.
stdout is unchanged: the same one-document payload, with the same block
fields. The mirror is emitted once, and only for a real admission block.

Sudo auto-fill still rewrites a leading sudo only. That boundary was
correct but invisible: `cd /data/app && sudo docker compose up -d` died
with "sudo: a password is required" 29 times in one session with no
explanation. Warn before connecting when a command runs sudo in command
position after a segment or wrapper, and explain the refusal afterwards
when the remote output shows a sudo password prompt. Both hints write
straight to stderr, because a caller reading a failed run may have
quieted diagnostics; the run path names the failing target. The auto-fill
scope itself is unchanged.

commandTokenIndex is extracted from commandInPosition so sudo position
detection shares the shell segmentation rules, and sudo gets its own
walker because commandWrappers already skips it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record the three session-analysis fixes: the machine-readable block
predicate (exit_code=-1 + error_kind=blocked + phase=admission +
executed=false) with the guarded sshx sql --docker= alternative, the
monitoring contract of sshx text (stderr-only progress, partial-scan
warning, expected_scan_bytes), and the auto-fill boundary that only the
first token of a command can be rewritten.

Covers README, README_CN, usage.go, AGENT.md section 7.4 and the
Unreleased CHANGELOG section, including the measured 83.0 s -> 26.5 s
scan timing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Guard the reported defects end to end against a real SSH/SFTP server: a
window larger than a read packet stays complete, a budget-limited scan
warns on stderr with a joined reason while stdout keeps one JSON
document, a fast scan stays quiet, a blocked command mirrors its reason
without touching stdio, and a non-leading sudo is warned about before
connecting and explained after the remote refuses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mira-voss and others added 2 commits September 22, 2026 23:01
Route every best-effort stderr note (block mirror, sudo hints, scan
progress, truncation warning) through one helper that checks the write
error and records it at debug level, so a failed diagnostic write can
never change a command outcome. The helper writes only to the injected
writer, keeping --json stdout pure.

Found by golangci-lint's errcheck; the CI lint job never got this far
because config verification failed first.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The lint job pins golangci-lint `version: latest`, which resolved to
2.13.2 and now rejects the retired top-level `issues.exclude-rules` key,
so `golangci-lint config verify` aborts before any file is linted. The
job is red on main for the same reason.

Move the rule to `linters.exclusions.rules` (the v2 location). The rule
itself is unchanged, `config verify` passes, and the migrated config
still reports the same findings: 0 issues on main and the 6 errcheck
findings fixed in the previous commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants