Repository navigation
Take the default answer at the downloader's retry prompt when stdin is closed - #3965
Thanatos9404 wants to merge 15 commits into
Conversation
When a download fails and nobody can answer "Retry? [n/y/e]" because stdin is closed, as in a Docker or Heroku build that runs `python -m nltk.downloader <package>`, input() raised EOFError and the downloader aborted with a traceback. Treat end of input like an empty answer, "n": do not retry and carry on, which is also what the downloader does with --quiet. Interactive answers are unchanged. The regression test replays the failures reported in nltk#3167 (Errno 99) and nltk#3144 (Errno 104) offline, with stdin at EOF. A second test checks that typed answers still behave as before: "n" or an empty line goes on, "e" stops, and "y" retries. Fixes nltk#3167. Refs nltk#3144 (the RuntimeWarning in that log is nltk#2162).
alvations
left a comment
There was a problem hiding this comment.
Verdict. The change is correct, small and safe: with stdin at EOF the retry prompt now takes the answer an empty line gives (n), so python -m nltk.downloader <package> in a Docker or Heroku build reports the failed package and goes on instead of dying with EOFError. I reproduced the crash on the parent commit (77b6d7a, exit 1, EOFError at nltk/downloader.py:1614) and confirmed it gone on the head (9857b2b, exit 0, error reported), both with a mocked urlopen and against the live index. Two things before merge: the same "nobody can answer" condition with a missing stdin (0<&-, a service without a console) still crashes on this branch, and the test matrix has not run yet because the workflow is waiting for approval.
Findings
1. should fix (nltk/downloader.py:1615 to 1620). With fd 0 closed rather than at EOF, input() raises RuntimeError: lost sys.stdin, not EOFError, and the head still exits 1 with a traceback. Ran on the head: python3.13 -W ignore::RuntimeWarning -m nltk.downloader -d ~/scratch punkt 0<&- with urlopen patched to raise URLError(OSError(99, ...)); output ends RuntimeError: lost sys.stdin, exit status 1. This is the same case as the PR describes (no one at the prompt), only with Python's other way of saying so, so the handler should take it too. Python raises this one only from input() when sys.stdin is None, so catching it here is narrow.
try:
choice = input().strip()
except (EOFError, RuntimeError):
# Nobody can answer: stdin is at EOF or missing, as
# in a Docker or CI build (#3167). Take the answer
# an empty line gives, "n": do not retry.
choice = "n"
A test for it is one line next to the EOF one: monkeypatch.setattr("sys.stdin", None).
2. should fix, pre-existing and adjacent (nltk/downloader.py:1031 and 1622 to 1623). In the reporters' scenario the index itself fails to load, and incr_download yields ErrorMessage(None, ...) (line 1031). A person who then types y at the prompt hits msg.package.id on None and gets AttributeError: 'NoneType' object has no attribute 'id'. Ran on the head and on the parent with printf 'y\nn\n' piped in: both crash the same way, exit status 1. Not introduced by this PR, but the new typed-answer test deliberately steps around it (its comment: "The package itself fails (not the index), so y has a package to retry"), which leaves the one path the reporters would take untested. Since the y branch is inside the lines this PR edits, it should retry what was asked for when there is no package:
if choice in ["y", "Y"]:
# An index failure carries no package: retry the
# id that was asked for.
retry = msg.package.id if msg.package else info_or_id
if not self.download(
retry,
and the typed-answer test should gain an index-failure case for y (the urlopen patch the EOF test already uses, with "y\nn\n" as the answers, expecting two prompts and True). If you would rather keep this PR to the EOF fix, say so and it can go in a follow-up.
3. not a code finding, blocks merge (CI). gh pr checks 3965 shows only the labeler; the ci-workflow run 38258196396 for 9857b2b is action_required, so the matrix has not run. A maintainer has to approve the workflow for this first-time contribution. Locally on python3.13 (macOS) the PR's tests and the downloader suites are green, see the table.
4. nit (PR description, "Behaviour change: exit status"). The description is accurate: __main__ never calls sys.exit on a failed package (if not rv and options.halt_on_error: break, line 3910), so n, e, -q and -e all exit 0 today. Verified each on the head with a failing urlopen and stdin at /dev/null: -q exit 0, -e exit 0, typed e exit 0 (then the next package prompts again and takes EOF as n). No change requested; if the maintainers want a non-zero status after any failed package, that is the separate follow-up the description offers, and #1825 is the earlier thread for it.
What was verified
| What | Command or method | Result |
|---|---|---|
| Branch state | git fetch origin pull/3965/head, merge-base with origin/develop |
1 commit on top of 77b6d7a, 0 behind, no conflicts |
| New tests on head | python3.13 -m pytest nltk/test/unit/test_downloader.py -k retry_prompt |
6 passed |
| New tests on parent | same, with nltk/downloader.py from origin/develop |
2 EOF cases fail with EOFError at line 1614, 4 typed-answer cases pass |
| CLI crash on parent | runpy.run_module("nltk.downloader") with nltk.pathsec.urlopen raising URLError(OSError(99, ...)), stdin </dev/null, packages punkt stopwords |
traceback, EOFError, exit 1 |
| CLI on head, same | as above | both errors printed, two prompts, exit 0 |
| CLI on head and parent, live index | python3.13 -m nltk.downloader -d <scratch> zzz_not_a_package </dev/null (the STR of #1825) |
parent: EOFError, exit 1; head: "not found in index", exit 0 |
| fd 0 closed on head | same CLI with 0<&- |
RuntimeError: lost sys.stdin, exit 1 (finding 1) |
y after an index failure, head and parent |
same CLI with printf 'y\nn\n' piped in |
AttributeError on msg.package.id, exit 1 (finding 2) |
Typed e, -q, -e on head |
same CLI, stdin </dev/null or piped |
each exit 0, matching the description |
| Downloader suites, network allowed | pytest nltk/test/unit/test_downloader*.py nltk/test/unit/test_attack_downloader*.py (12 files) |
724 passed, 1 skipped, 0 failed (5 min 32 s) |
| Downloader suites, network blocked | same, with a plugin that refuses every non-loopback connect() and DNS lookup, -k "not live_index" |
718 passed, 2 skipped, 4 failed: the 3 tests that fetch real data (the same 3 the description names) and test_concurrent_downloads_cooperate, whose multiprocessing Unix socket the guard also refused (a guard artifact, not a defect) |
| Security probes | python3.13 -m nltk.test.unit.security_probes |
65 probes, 65 FIXED |
| Hooks | python3.13 -m pre_commit run --files AUTHORS.md nltk/downloader.py nltk/test/unit/test_downloader.py |
all passed (black, isort, ruff, pyupgrade, and the sandbox, print, regex, pickle, spawn, eval, csv and json guards) |
| Chokepoints | grep -n urlopen nltk/downloader.py |
urlopen is nltk.pathsec.urlopen (line 186); the PR adds no open, load, save, spawn or print call |
| Style rules | grep of the diff for double hyphens; comment block length; commit trailer | none; 3 lines; no attribution trailer |
| CI | gh pr checks 3965, gh api repos/nltk/nltk/actions/runs?head_sha=9857b2bad |
labeler passed; ci-workflow 38258196396 action_required (finding 3) |
| Cross references | #3167, #3144, #1825, #2162, the #3883 issue map | #3144 is listed there as "#3167 plus #2162"; this PR fixes the #3167 half, as the description says |
Transparency, per #3883.
- Tool and model: Claude Code with Claude Fable 5.1, operated and reviewed by the maintainer.
- Prompt (the maintainer's instructions, verbatim): "Launch an agent to review the #3965 with fable model and post the reply on the PR"
- Audit trail:
- Read the PR (1 commit, 3 files, +75/-2), its description, every comment and review (none yet), issues #3167, #3144, #1825, #2162 and the #3883 issue map entries for #3167 and #3144.
- Fetched the head into a detached worktree and a second worktree at the parent commit; python3.13; data roots read-only behind an empty scratch root (it stayed empty).
- Ran the 6 new test cases on the head (6 passed) and against the parent's
downloader.py(2 failed withEOFError, 4 passed). - Reproduced the crash at the command line on the parent and showed it gone on the head, with a mocked
urlopenand against the live index; probed the0<&-,yafter index failure, typede,-qand-eshapes and recorded each exit status. - Ran the 12 downloader test files twice: with the network allowed (724 passed, 1 skipped, 0 failed) and under a loopback-only socket guard (718 passed, 4 failed, each explained by the guard).
- Ran the 65 security probes (65 FIXED) and the pre-commit hooks on the 3 changed files (all passed).
- Checked the workflow runs for the head SHA: the matrix is
action_requiredand has not run.
|
Thanks for the proposal, thanks for following #3883 and as much as possible put effort to xhigh if you're using an agent to help you. Please address the comments above. |
A process started with fd 0 closed, or a service without a console, has
sys.stdin set to None, and input() then raises RuntimeError ("lost
sys.stdin") rather than EOFError. It is the same situation as nltk#3167,
nobody can answer, so the prompt takes "n" for that too. input() raises
RuntimeError only for a missing stdin or stdout, so the catch stays
narrow. The test drives the real download() with sys.stdin None.
In the reports behind nltk#3167 and nltk#3144 it is the index fetch that fails, and incr_download then yields ErrorMessage(None, ...); for a list request it yields the id string. Answering "y" at the prompt then crashed on msg.package.id with AttributeError. The retry now takes the Package's id when there is one, else the id or request that failed. The retried attempt also reports to print_error_to, where the first attempt reported, instead of to sys.stderr.
A proxy's HTML error page, an empty body, a duplicate attribute, or a package or collection element missing an attribute its constructor needs made _update_index raise ElementTree.ParseError or TypeError. Neither is caught by the (OSError, ValueError) handler around the lookup, so download() ended in a traceback, and a non-interactive run such as the Docker build of nltk#3167 died on it. Both are now a ValueError with the sanitised reason, reported like a refused URL and raised only under raise_on_error. The index is also assigned only after every entry parsed, so a refused index is not cached as fresh: the same Downloader refetches on the next request and installs once the server serves a good index. The test drives each shape through a real local server and checks the error line, the empty download directory and the refetch.
Attack-suite cases for the prompt against the local server: with stdin at EOF or absent, a collection with one bad package installs the others and reports the bad one once; "e" stops before the next package; "y" refetches the failed package once and goes on; "y" after an unusable index refetches the index. Each is judged by what is on disk afterwards and by the captured lines staying clean.
ProxyHandler adds the credentials of a proxy URL, such as HTTPS_PROXY=http://user:password@proxy:3128, to each request it routes through that proxy as an ordinary Proxy-Authorization header, and HTTPRedirectHandler copies ordinary headers onto the redirected request. When that request is not proxied (its host is in NO_PROXY or the system bypass list), the credentials went to its host. Add _ProxyCredentialsRedirectHandler, which drops the header from the redirected request, and make _ValidatingRedirectHandler a subclass of it: pathsec.urlopen validates the new URL first, then drops the header. ProxyHandler adds it again when the redirected request is proxied. The SSRF attack harness gets cases that run pathsec.urlopen against stub servers on 127.0.0.1, with the credentials in HTTP_PROXY and HTTPS_PROXY, for each redirect code: a redirect to a NO_PROXY host must not receive the header, and a redirect the proxy carries must still authenticate. This comes first because the fix for nltk#3109 makes nltk.set_proxy put its user and password in the proxy URL too. Refs nltk#3109.
nltk.set_proxy(proxy, user=..., password=...) only installed ProxyBasicAuthHandler, which answers a 407 to a plain HTTP request. An HTTPS request goes through a CONNECT tunnel that http.client opens before any handler sees a response, so the proxy got no credentials and nltk.download failed with "Tunnel connection failed: 407 Proxy Authentication Required". Put the credentials in the proxy URL given to ProxyHandler, percent-encoded so that an "@", ":" or "/" in them stays data. urllib sends them as Proxy-Authorization with every request it routes through the proxy, and urllib.request.AbstractHTTPHandler.do_open moves that header onto the CONNECT and keeps it from the origin server. pathsec.urlopen already copies the proxy URL from the installed opener, so the downloader picks them up. The basic and digest handlers stay for plain HTTP. The scheme and host come from urllib.request._parse_proxy, the parser ProxyHandler itself uses, so the credentials go to the host the requests go to, also when the proxy URL holds another URL in its path, query or fragment. Credentials already in the URL are replaced, and a proxy without a scheme still works. ProxyHandler adds the credentials as an ordinary header, which urllib's redirect handler copies onto a redirected request. So the opener that set_proxy installs gets pathsec's _ProxyCredentialsRedirectHandler from the previous commit: a redirect to a host that bypasses the proxy (NO_PROXY) must not receive the credentials, also through a plain urllib.request.urlopen, and ProxyHandler adds them again when the redirected request is proxied. password=None is treated like the default empty password. The (user, password) tuple that web/data.rst passed as the second argument is still accepted; a user or password that is not exactly a str now raises TypeError instead of being sent in its str() form. Also fix the web/data.rst example to pass user= and password=. The SSRF attack harness gets the set_proxy cases: the redirect cases of the previous commit, the CONNECT of an HTTPS request (with the credentials from set_proxy or from HTTPS_PROXY), hostile credential strings, and proxy URLs with another URL after the host. Fixes nltk#3109.
|
Thanks @alvations for the review and for pushing the follow-up commits (missing stdin, (Drafted with Claude Code / Claude Opus 5.5 and posted on my behalf, per #3883.) |
|
Run a review using your agents and see what it catches. Read #3883 carefully and try to solve the issues as a cluster not individual band-aids. Thank you in advance! |
Under a proxy, pathsec.urlopen left the pinning handlers out because they could not open a CONNECT tunnel. The operator's opt in vouches for the proxy, but urllib still makes some connections itself: to a host in NO_PROXY or the system bypass list, to a redirect target that leaves the proxy, and to the proxy a caller's Request names. Those went out by host name after validate_network_url had resolved it once, so a name that validated as public could rebind to loopback before the connection. The pinning handlers are now always installed. They leave a connection to urllib only when ProxyHandler routed the request to one of the configured proxies: a plain HTTP request to it, or the CONNECT of a tunnel through it. The proxy hosts are read with urllib.request._parse_proxy, the parser ProxyHandler uses, and the environment proxies are read once and handed to the ProxyHandler that uses them. The SSRF harness gets section 21: a rebinding name behind a redirect off the proxy (credentials from the environment or from set_proxy, 302 and 307, http and https), the same name as the first hop under set_proxy, and a Request that names its own proxy. It also pins what _ProxyCredentialsRedirectHandler is still for, a redirect to a second proxy, with a teeth case that puts urllib's own redirect handling back and sees the first proxy's credentials on the second proxy's CONNECT. test_env_proxy_opt_in_skips_pinning_handlers asserted the old design, no pinning handlers under an opted in proxy. It is replaced by a test that the environment's ProxyHandler keeps its proxies and that both pinning handlers trust exactly that proxy. Refs nltk#3109.
set_proxy percent-encodes the user name and password into the proxy URL, but ProxyHandler unquotes them again and sends "user:password" in Basic form. A ":" in the user name therefore moves the split: for the user "[email protected]:8080/" the proxy reads the user "[email protected]" and a password that starts with "8080/". The hostile credential cases compared the header string, which hid this. RFC 7617 also allows no control character in either part. set_proxy now raises ValueError for a ":" in the user name or a control character (C0 or DEL) in the credentials, before it replaces the installed opener. The message does not repeat the credentials. A ":" in the password is still fine. The two harness cases that hit this, the colon in the user name and the CR LF in the password, move to a test that they are refused and that the proxy receives nothing, with a NUL case next to them. Their places in the "reach the proxy as data" cases go to a user name with "@" and "/" and to a password that reads as another proxy. Refs nltk#3109.
The retry prompt was the only one of the downloader's eight input() calls that survived a closed or missing stdin. python -m nltk.downloader with no package, or nltk.download() with no argument, opens the text shell when there is no display, as in a Docker or CI build, and each of its prompts still ended in EOFError, or RuntimeError for a lost sys.stdin (nltk#3167). Taking an empty line at EOF everywhere would not do: the main menu and the config menu ask again on an empty line, so they would ask forever. All eight prompts now go through _answer(prompt, at_eof). At EOF it gives the answer an empty line gives, except "q" at the main menu and "m" in the config menu. So the retry prompt goes on as before, the listing pages on to the end, and every other prompt is left. The prompt goes through sanitize_terminal like any other text the downloader writes. The download harness gets section 15: the shell driven against a real local server with stdin at EOF after each sequence a user could type, and with no stdin at all. Its stdin fails the test instead of hanging if a prompt keeps asking after the end. A test runs python -m nltk.downloader in a child process with stdin at EOF and the text shell forced. Refs nltk#3167, nltk#3144.
|
Thanks @alvations. I reran the review on the whole branch, your four commits included, and started from #3883 this time. In the #3883 map the download failures cluster (C1) has two code defects, #3167 and #3109, and #3144 is listed as #3167 plus #2162. So I am treating this PR and #3970 as one piece of work: I merged the #3970 branch and current develop into this branch and added the commits below on top. Nothing was rebased. If you would rather keep two PRs, say so and I will split it again. What the review caught on the downloader side:
The proxy side, including your thread on Tests: in the five test files this branch touches, 29 tests fail on the merged head without these commits and pass with them. On Windows with Python 3.14 the five files give 617 passed and 31 skipped; the only 2 failures are symlink cases that need a Windows privilege this machine lacks, and they fail the same way without these commits. The security probes give the same verdicts with and without the commits (48 FIXED, 14 STATIC, 4 ERROR from the same symlink privilege), with none VULNERABLE. The shell cases run against a real local server, and the command line cases run a real child One question from the description is still open. With stdin closed, a failed package now makes the command exit 0, as Drafted with Claude Code, posted on my behalf. |
|
AI;DR so what’s the conclusion on the initial exploit claim that the enforce is not working? Should we patch it? Or are the tests more tests on benign and not malicious since the enforce is doing what it should. This PR has since moved away from the original patch for the enforce flag to some older ticket proxy issues that usually comes from universities or schools firewalls blocking proxies, so it’s best we don’t try to find ways of getting around those settings. And on heroku deployment did you really try it there? Or just mock tests? I see that actual Linux instance deployment with enforce true working as expected. The changes you made on the http proxies seem to unfortunately open new threat vectors to the downloader. If you don’t mind, do a human check on actual pentester playbook manually and work through it. Otherwise we might have to wait for another team member to verify your claims before proceeding with this and similar PRs. We want to avoid any possible new vectors intended the library, intended or unintended. —— If this PR is still on the original enforce exploit, please do help and see if you can run Fable and on xhigh to validate your findings, esp. when we’re unable to replicate the claimed exploit. See #3981 To agent, put the related PRs together first and then once we get pass them as a coherent review, we split them up again before full matrix run. Independent of nltk, create a fork and then run the full-matrix tests first before trying to finalize your additional tests and patch. But first make sure if the proxy is even a relevant issue beyond users organization rules. Do this, deploy the windows and Linux and macOS instance using your human users GitHub action to actually verify the proxy issues and if the GitHub actions doesn’t give any errors without all the proxy related issues that an organization might put i.e. plain production deployment scenarios then it might not be a real problem to solve. If so, then remove all the code from this PR and make sure you recheck the claims and rework this PR. |
|
Sorry for the length last time. Short answers:
Drafted with Claude Code, posted on my behalf. |
|
I give up… I don’t understand the PR already… @Thanatos9404 please rework your PR and instruct the agent clearly what you want to achieve, the context has probably drifted. I’ll leave it to other NLTK members to answer your PRs or close it. The delta from the develop is now doing something entirely different and just doing sanitize terminal effectively. The agent context must have totally drifted. Suggest you close this and the other PRs you’ve opened, please don’t just spam agents to just look at issue and send patches and follow up with little to no human supervision or specific goals. |
|
Thanks for the time you spent on this, and sorry it became hard to follow. Closing this and #3970. |
Fixes #3167.
Refs #3144, which reports the same crash from a Heroku build. The issue map in #3883 lists #3144 as "#3167 plus #2162"; the RuntimeWarning from #2162 is a separate defect and is not touched here.
What and why
When a download fails and
download()runs withhalt_on_error=False, the downloader printsError installing package. Retry? [n/y/e]and callsinput().python -m nltk.downloader <package>runs it that way. In the Docker build of #3167 and the Heroku build of #3144, stdin is closed, soinput()raisesEOFError(nltk/downloader.py:1614 on develop). The command then dies with a traceback instead of reporting the failed package.How
EOFErrorfrom thatinput()is now taken as the answer an empty line gives,n. The downloader does not retry and carries on with the remaining packages, which is also what--quietdoes. Answers typed at a terminal or piped in work as before. Only the case that crashed changes.Behaviour change: exit status
With stdin closed,
python -m nltk.downloader <package>now prints the error, skips the package and exits with status 0. Before, it crashed withEOFErrorand exit status 1. Status 0 after a failed download is what the command line already gives today when the user answersnore, or passes-qor-e.So a script that relied on the crash to stop, such as the Heroku buildpack in #3144 (its log ends with "Could not download NLTK Corpora: exit status 1"), will no longer fail at that point. It will carry on without the package.
If you want a failed download to make the command exit non-zero, I can do that as a follow-up. It would change the
n,e,-qand-ecases too, so it is your call. For context: #1825 (2017–2019) asked to skip this prompt when no TTY is attached. A maintainer pointed to--exit-on-error(-e) and--quiet(-q), and the issue was closed in 2019 without a code change.If you would rather have EOF mean
e(stop and return False), it is a one-word change. The exit status would still be 0.Tests
test_retry_prompt_at_eof_answers_noreplays both reports offline. The index fetch fails with the reported error ([Errno 99] Cannot assign requested addressforpunktin urlopen error [Errno 99] Cannot assign requested address> #3167,[Errno 104] Connection reset by peerforstopwordsin using nltk.txt for downloading stopwords #3144), and stdin is at EOF. On develop, both cases fail withEOFErrorat nltk/downloader.py:1614.test_retry_prompt_typed_answers_are_unchangedchecks that answers typed or piped in behave as before:nand an empty line go on,estops,yretries. It passes on develop and on this branch.All 6 cases pass on Windows with Python 3.10, 3.13 and 3.14. The downloader test files (
test_downloader.py,test_downloader_*.pyandtest_attack_downloader_*.py, with the network-onlylive_indextest deselected) were also run with a guard that blocks every non-loopback connection and DNS lookup. On all three versions: 640 passed, 83 skipped, 3 failed. The 3 failures are the existing tests that download real data (test_downloader_using_existing_parent_download_dir,test_downloader_using_non_existing_parent_download_dir,test_downloader_redownload), whose lookup of raw.githubusercontent.com the guard blocks. develop fails the same 3 the same way. The skips are POSIX-only cases (permission bits, symlinks,dir_fd), archives that are not installed, and one test marked as needing the network. black, isort, ruff, pyupgrade and the repository's guard hooks pass on the changed files.Related
#1825 (2017, closed 2019) reported the same prompt hanging in a pipeline and asked to skip it when no TTY is attached. This PR is narrower: it changes only the case where there is no input at all.
Transparency (per #3883)
Prompt
(local paths redacted; "…" marks omitted boilerplate about local paths and tooling)
nltk.downloaderCLI throughrunpy, withnltk.pathsec.urlopenraising the reportedURLErrorand stdin at EOF. This gave the reporters' traceback ending inEOFErrorat line 1614, with exit status 1.n,ereturns False,-qskips the prompt and carries on, andnltk.download()defaults tohalt_on_error=True, so it never asks. Chose: EOF means an empty answer.