Skip to content

Fix SFTP after close, harden SSH and Subprocess execution, enforce the lint baseline - #174

Merged
penguinolog merged 8 commits into
masterfrom
fix-sftp-after-close-v2
Oct 29, 2026
Merged

penguinolog merged 8 commits into
masterfrom
fix-sftp-after-close-v2

Conversation

@penguinolog

Copy link
Copy Markdown
Collaborator
  • Fix: SFTP after close() reconnects instead of failing with AttributeError on a None transport.
  • Fix: no unknown host key warning for the local host when the server presents one of this machine's host keys; any other key is handled by the configured policy as before (still a warning by default).
  • Harden: execute context debug strings no longer print the command (masked), STDIN data or environment values.
  • Harden: failed connection attempts close their paramiko transport; a supplied socket is not retried.
  • Add: optional SSHClient(connect_timeout=...) (TCP, banner, key exchange, authentication; also for jump hosts and proxy_to).
  • Add: opt-in Subprocess(shell=False) and async_api.Subprocess(shell=False) execute argument lists without a shell (TypeError for strings).
  • Tooling: ruff/mypy/pylint updates with D and PIE enforced, pyrefly added (tox env, CI job), pyproject.toml formatted by pyproject-fmt.
  • # noqa: S602 and # noqa: S603 on Popen: running the command line is the purpose of Subprocess.
  • Behaviour changes: SSHAuthMapping item methods use key/item parameter names; the own key of the local host no longer reaches the missing host key policy.
  • 8 commits, one per kind of change, each with passing tests, ruff and mypy; tox passed on py311-py315, the lint environments, docs and pypy311 (not re-run for this history).
what from to
ruff >=0.15.13 >=0.17.3
mypy >=2.1 >=2.4.1
pylint >=4.0.5 >=4.1.3
pyproject-fmt >=2.21.2 >=2.30.3
prek >=0.4.1 >=0.5.7
pyrefly absent >=1.4.0
CI pylint >=3.0.0 dependency group
CI mypy --strict --install-types [tool.mypy], stubs in the types group

Formatter output only, so the next change shows only real edits to it:

- Tables and keys are reordered and sorted by the tool.
- Lists of values are sorted where order does not matter.
- No value is added, removed or changed.
Raise the checker floors to the current releases and bring the project up to
the baseline rule set. ruff and pyproject-fmt revs in pre-commit match the
dependency groups; CI installs pylint and mypy from the same groups.

- Enable the D (pydocstyle) and PIE rules; fix the docstrings they report.
  Tests ignore only the missing-docstring rules D1.
- Fix ICN001 (datetime imported as dt), FURB107 and BLE001 (narrow noqa with
  a reason), pylint arguments-differ against Callable and a useless
  suppression.
- Suppress S602 on Subprocess.Popen: running a shell command line is the
  purpose of the class (approved by the maintainer).
- Add types-paramiko, types-psutil and types-pyyaml to the mypy group
  instead of --install-types; drop the psutil ignore_missing_imports.
- Enable mypy ignore-without-code; strict comes from [tool.mypy], not flags.
- Fix the "ommands" typo in the bandit tox env, so bandit now actually runs.
- Remove the unused [tool.isort], the wheel build requirement, the dangling
  pep257 tox env, and options that restate a default.

| what | from | to |
| --- | --- | --- |
| ruff | >=0.15.13 | >=0.17.3 |
| mypy | >=2.1 | >=2.4.1 |
| pylint | >=4.0.5 | >=4.1.3 |
| pyproject-fmt | >=2.21.2 | >=2.30.3 |
| prek | >=0.4.1 | >=0.5.7 |
| ruff-pre-commit rev | v0.15.13 | v0.17.3 |
| pyproject-fmt rev | v2.21.2 | v2.30.3 |
| CI pylint | >=3.0.0 | dependency group |
SSHClient closes the paramiko client on the outermost context exit (and on
reconnect()), leaving it without a transport. Commands reconnect through
_ssh_transport, but SFTP called SSHClient.open_sftp() directly, so the first
SFTP call after a close failed with:

  AttributeError: 'NoneType' object has no attribute 'open_sftp_client'

- __connect_sftp opens the channel with _ssh_transport.open_sftp_client(),
  which reconnects under the client lock when the transport is missing.
- A failed reconnect propagates as is; it is not reported as unavailable SFTP.
- Add regression tests for SFTP after close and for a failed reconnect; the
  existing mocks provide open_sftp_client on the transport.
The repr of the SSH, Subprocess and async Subprocess execute contexts printed
the command, the STDIN data and the environment as they are, although they
commonly hold passwords and tokens.

- The command is shown with the client's log_mask_re applied; ExecuteContext
  takes the rule as an optional argument.
- STDIN is shown by its size and the environment by variable names only.
- Add tests for the three contexts, including environments with mixed key
  types.
Subprocess and async_api.Subprocess always ran the command line through a
shell. Add an opt-in shell argument (default True, nothing changes).

- With shell=False the command must be a list of arguments: execute() raises
  TypeError for a string and ValueError for an empty list.
- The command stays a joined string in logs and ExecResult.cmd and is split
  into arguments only when the process is created (Popen without a shell,
  asyncio.create_subprocess_exec).
- chroot is applied to the arguments, without sh -c inside the chroot.
- SSHClient is not changed: the remote side always uses a shell.
- The execute contexts take use_shell, so the shell= keyword is not passed
  around (bandit reports B604 on it).
- Accept the S603 finding on Popen: the arguments come from the caller.
A stalled or rejected connection attempt blocked connect for a long time and
leaked paramiko's transport thread and socket.

- SSHClient takes connect_timeout, default None (nothing changes). SSHAuth.connect
  takes timeout and passes it to paramiko as timeout, banner_timeout and
  auth_timeout. It is applied at every connection step: the direct connection,
  a caller socket, each jump host of a chain and the clients created by
  proxy_to and execute_through_host. A value that is not positive raises
  ValueError.
- SSHAuth.connect closes the client after every failed attempt, before the next
  key is tried and before an error is raised. A caller socket is closed with the
  failed attempt, so no further key is tried on it.
- The connection chain closes every client created for it, in reverse order, if
  a host of the chain cannot be reached.
- A connection on a supplied socket is not retried: the failed attempt has
  closed it, so a retry would only fail with OSError and hide the first error.
Connecting to the own machine (127.0.0.1, localhost, the own host name) printed
the paramiko WarningPolicy warning every time, because the local host is
normally absent from known_hosts. Nothing that worked before may fail, so the
host key is still checked by the configured policy.

- Add is_local_host: the loopback range, localhost, own host name or its short
  form, and addresses of local interfaces. It does no name resolution, treats an
  unscoped link-local address as not local, and answers "not local" if the
  interface list cannot be read.
- Add local_host_keys: the public keys of this machine's SSH server
  (/etc/ssh/ssh_host_*_key.pub), read on every call; unreadable and malformed
  files are skipped.
- A direct connection to a local host (no proxy command, jump host or caller
  socket) uses LocalHostKeyPolicy: a server key equal to a host key of this
  machine is accepted, every other key goes to the configured policy. The
  default policy still warns and RejectPolicy still rejects, on any address and
  port. A known_hosts entry is checked first, as before.
- A failed lookup of the local host or its keys leaves the configured policy.
- Tests do not read the host keys of the machine running them.
pyrefly runs next to mypy: it reports different errors, and the team uses its
language server, so a finding of it shows up in their editors.

- [tool.pyrefly] checks the package and the tests for the project's minimum
  Python version; the version file written by setuptools-scm is not required.
- Add the pyrefly dependency group and a pyrefly tox environment; the stub
  packages move to a types group shared with mypy. Add a PyRefly CI job that
  the Test job waits for.
- Make the code and the tests pass it, without behaviour changes: format the
  ExecResult error messages with format_map; name the SSHAuthMapping item
  method parameters as UserDict does; drop the redundant explicit context
  manager bases of the execute contexts; build the execute results with _make,
  as their field types are narrowed by properties; mark the narrowing
  properties and the optional imports with pyrefly: ignore[...] and the reason;
  annotate dictionaries in the tests.
@penguinolog
penguinolog requested a review from dis-xcom as a code owner October 29, 2026 15:31
Comment thread exec_helpers/_ssh_base.py Dismissed
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 40902289121

Coverage increased (+14.0%) to 67.537%

Details

  • Coverage increased (+14.0%) from the base build.
  • Patch coverage: 9 uncovered changes across 5 files (160 of 169 lines covered, 94.67%).
  • 2 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
exec_helpers/_ssh_base.py 37 34 91.89%
exec_helpers/async_api/subprocess.py 26 24 92.31%
exec_helpers/subprocess.py 21 19 90.48%
exec_helpers/exec_result.py 10 9 90.0%
exec_helpers/ssh_auth.py 15 14 93.33%
Total (10 files) 169 160 94.67%

Coverage Regressions

2 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
exec_helpers/api.py 1 58.14%
exec_helpers/async_api/api.py 1 49.69%

Coverage Stats

Coverage Status
Relevant Lines: 1897
Covered Lines: 1347
Line Coverage: 71.01%
Relevant Branches: 438
Covered Branches: 230
Branch Coverage: 52.51%
Branches in Coverage %: Yes
Coverage Strength: 5.68 hits per line

💛 - Coveralls

@penguinolog
penguinolog merged commit dc6220a into master Oct 29, 2026
47 checks passed
@penguinolog
penguinolog deleted the fix-sftp-after-close-v2 branch October 29, 2026 15:37
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.

3 participants