Skip to content

Commit a5b665f

Browse files
jeremymanningclaude
andcommitted
Issue #130: Close the guard bypasses red-teaming found, and fix my own claims
Red-teaming the branch found that my Step 1 change traded a false refusal for two real money-safety holes. Verified against master, which refused both: PYTEST_ADDOPTS=tests/integration/test_x.py pytest -> collected 2 pytest -o testpaths=tests/integration/test_x.py -> collected 2 Cause: `config.invocation_params.args` is only what the operator typed. `testpaths`, `-o testpaths=` and PYTEST_ADDOPTS all feed `config.args` without appearing there. Reading the typed argv makes the guard blind to every indirect route. The right fix was in the config, not the guard. Reading `config.args` is safe now precisely because Step 2 set `testpaths = ["tests"]`, so the guard is back on the effective target list. Also fixed, and pre-existing (#109): relative paths were resolved only against rootdir, so from within tests/ the path `integration/test_x.py` went unrecognised. All plausible bases are now tried. `--pyargs` and `-p` address modules by dotted name and never looked like paths; both are now translated and checked. Measured after the change -- refused: explicit dir, explicit file, node id, -o testpaths, PYTEST_ADDOPTS, --pyargs, cwd-relative. Still allowed and must be: bare `pytest` (0 integration nodes collected) and the CLUSTRIX_ALLOW_BILLABLE=1 opt-in. `-p` cannot be blocked before the import it triggers -- no conftest hook runs that early -- but the run is now refused before any test executes, which is where the cost is; it also fails on its own here, since tests/ is not a package. Four parametrized regression tests cover the indirect routes. Three fail against the old guard, confirming they are not decorative. Corrections to my own earlier work in this PR: * "the 224 in tests/real_world/" was wrong and had been committed as a code comment in scripts/pre_push_check.py. 224 counts decorator lines; tests/real_world/ collects 388 tests. That comment's rationale was also wrong: the old bare `pytest` was not "harmless while no config applied", it aborted on master in seconds with a collection error and gated nothing. Fixing that SyntaxError is what let collection succeed and made the runtime problem visible. * test_no_shadowing_config_file_exists forbade tox.ini and setup.cfg. pytest checks those *after* pyproject.toml, so neither can shadow it -- verified. The test also failed on an ordinary pytest-free setup.cfg holding flake8 config. Now limited to pytest.ini/.pytest.ini, and it checks the repo tree rather than the resolved rootdir. * test_bare_pytest_is_not_refused_by_the_guard described the guard source that no longer exists; rewritten to state the actual invariant. * coverage_detailed_report.txt was removed from .gitignore: nothing in the repo writes it, so the justification given was unsupported. Unrelated CI fix found while verifying: .github/workflows/fast_ci.yml installs pytest without pytest-timeout, then runs pytest with --timeout=60. It exits 4 with "unrecognized arguments" and has been failing on every run, including on master. Added the plugin. Restored the `filterwarnings` block from the deleted pytest.ini, which was never migrated. It only suppresses third-party paramiko/cryptography noise. The five markers pytest.ini also declared are deliberately not migrated: tests/real_world/conftest.py registers them with addinivalue_line where they are used. tests/unit/ 79 passed. 1674 collected, 0 errors. flake8 91 (master: 92). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
1 parent 5483917 commit a5b665f

7 files changed

Lines changed: 255 additions & 66 deletions

File tree

.github/workflows/fast_ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ jobs:
3939
- name: Install dependencies
4040
run: |
4141
python -m pip install --upgrade pip
42-
pip install black flake8 mypy pytest
42+
# pytest-timeout is required: the test step below passes
43+
# --timeout=60, and without the plugin pytest exits 4 with
44+
# "unrecognized arguments". This job had been failing on every run
45+
# for that reason (see #130).
46+
pip install black flake8 mypy pytest pytest-timeout
4347
pip install -e .
4448
4549
- name: Format check with Black

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ test-reports/
2626
.coverage
2727
.coverage.*
2828
coverage.json
29-
coverage_detailed_report.txt
3029
htmlcov/
3130
.tox/
3231
# Written by the benchmark tests in tests/real_world/ at run time.

pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,19 @@ markers = [
219219
"dartmouth_network: marks tests that require access to the Dartmouth campus network",
220220
"performance: marks performance benchmark tests",
221221
]
222+
# Carried over from the deleted pytest.ini, where it never took effect. These
223+
# suppress third-party noise only; nothing here hides a warning raised by
224+
# clustrix itself.
225+
# NB: markers visual/ssh_required/aws_required/azure_required/gcp_required are
226+
# deliberately NOT listed. tests/real_world/conftest.py registers them with
227+
# addinivalue_line at the point they are used, so --strict-markers is satisfied
228+
# there and they stay scoped to the suite that actually defines them.
229+
filterwarnings = [
230+
"ignore::DeprecationWarning",
231+
"ignore::PendingDeprecationWarning",
232+
"ignore::UserWarning:paramiko.*",
233+
"ignore::UserWarning:cryptography.*",
234+
]
222235

223236
[tool.coverage.run]
224237
source = ["clustrix"]

scripts/pre_push_check.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,23 @@ def main():
4343

4444
checks = [
4545
("black clustrix/ tests/", "Black formatting"), # Format, don't just check
46-
("flake8 clustrix/ tests/ --max-line-length=88 --extend-ignore=E203,W503,F401,E722,F541,F841,F811,E731,E501,W291,W293,F824", "Flake8 linting"),
46+
(
47+
"flake8 clustrix/ tests/ --max-line-length=88 --extend-ignore=E203,W503,F401,E722,F541,F841,F811,E731,E501,W291,W293,F824",
48+
"Flake8 linting",
49+
),
4750
("mypy clustrix/", "MyPy type checking"),
4851
# Must mirror what GitHub Actions actually runs (see
4952
# .github/workflows/tests.yml), or this script cannot deliver on
5053
# the promise in its own docstring.
5154
#
52-
# This was a bare `pytest`, which was harmless only for as long as
53-
# the project had no effective pytest config: pytest.ini shadowed
54-
# pyproject.toml and neither applied, so `testpaths` did nothing
55-
# (see #130). With the config live, a bare `pytest` resolves to
56-
# testpaths and pulls in all 1670 tests -- including the 224 in
57-
# tests/real_world/, which make live network and cloud calls and do
58-
# not terminate in a reasonable time. Real-world tests are run
59-
# deliberately via scripts/run_real_world_tests.py, not here.
55+
# This was a bare `pytest`, which gated nothing: on master it
56+
# aborted in seconds with "Interrupted: 1 error during collection"
57+
# (an f-string SyntaxError under Python < 3.12) and ran zero tests.
58+
# Fixing that error is what made the problem visible -- collection
59+
# then succeeded and a bare `pytest` ran all 1670 tests, including
60+
# the 388 in tests/real_world/, which open live SSH and cloud
61+
# connections. Real-world tests are run deliberately through
62+
# scripts/run_real_world_tests.py, not from this gate.
6063
('pytest tests/unit/ -m "not real_world"', "Tests"),
6164
]
6265

tests/conftest.py

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,66 @@ def _billable_tests_enabled():
1515
return os.environ.get(_OPT_IN_VAR, "").strip().lower() in _TRUTHY
1616

1717

18+
def _iter_candidate_targets(config):
19+
"""Yield every path-ish string this run could end up collecting from.
20+
21+
Reads `config.args` -- the *effective* target list -- rather than
22+
`config.invocation_params.args`, which is only what the operator literally
23+
typed. The difference is the whole ballgame, because there are three ways
24+
to aim pytest at a directory without typing its path:
25+
26+
PYTEST_ADDOPTS=tests/integration/test_x.py pytest
27+
pytest -o testpaths=tests/integration/test_x.py
28+
(testpaths in pyproject.toml)
29+
30+
All three land in `config.args` and none appear in `invocation_params.args`.
31+
Red-teaming confirmed the first two collected billable modules when the
32+
guard read the typed argv.
33+
34+
Reading `config.args` is only safe because `testpaths` is `["tests"]`. If
35+
it ever names tests/integration again, a bare `pytest` will be refused --
36+
loudly and correctly, since testpaths would then be pointing every default
37+
run at billable tests.
38+
"""
39+
for arg in config.args:
40+
yield str(arg).split("::")[0]
41+
# --pyargs addresses modules by dotted name, which never looks like a path.
42+
if getattr(config.option, "pyargs", False):
43+
for arg in config.args:
44+
yield str(arg).split("::")[0].replace(".", os.sep)
45+
# -p imports a plugin by dotted name, before collection begins.
46+
for plugin in getattr(config.option, "plugins", None) or []:
47+
yield str(plugin).replace(".", os.sep)
48+
49+
50+
def _targets_integration_dir(candidate, config):
51+
"""True if `candidate` names tests/integration under any sane resolution.
52+
53+
Paths on the command line resolve against the invocation directory, while
54+
`testpaths` resolves against rootdir. Those differ whenever pytest is run
55+
from a subdirectory, and resolving against only one of them was a real
56+
hole: from `tests/`, `pytest integration/test_x.py` was not recognised.
57+
58+
Both bases are tried. A guard protecting real money should over-match
59+
rather than under-match.
60+
"""
61+
raw = pathlib.Path(candidate)
62+
if raw.is_absolute():
63+
attempts = [raw]
64+
else:
65+
invocation_dir = getattr(config.invocation_params, "dir", None)
66+
bases = [invocation_dir, pathlib.Path.cwd(), pathlib.Path(str(config.rootpath))]
67+
attempts = [pathlib.Path(str(base)) / raw for base in bases if base]
68+
for attempt in attempts:
69+
try:
70+
resolved = attempt.resolve()
71+
except OSError: # pragma: no cover - defensive, unresolvable path
72+
continue
73+
if resolved == _INTEGRATION_DIR or _INTEGRATION_DIR in resolved.parents:
74+
return True
75+
return False
76+
77+
1878
def pytest_configure(config):
1979
"""Refuse to start when a run explicitly targets tests/integration.
2080
@@ -35,31 +95,20 @@ def pytest_configure(config):
3595
these tests by name gets told why they got nothing, rather than an
3696
inscrutable empty run.
3797
38-
Reads `config.invocation_params.args` -- what the operator actually typed --
39-
and NOT `config.args`. The two differ in exactly the case that matters: when
40-
no path is given on the command line, pytest populates `config.args` from
41-
`testpaths` in pyproject.toml, which lists `tests`. Keying off `config.args`
42-
therefore made a bare `pytest` abort with this very error the moment the
43-
project's config became effective (see #130). `invocation_params.args`
44-
contains flags as well as paths, so entries starting with "-" are skipped.
98+
Target discovery is in `_iter_candidate_targets` and path matching is in
99+
`_targets_integration_dir`; both carry the reasoning for why they look
100+
where they do. In short: read the *effective* target list, and resolve
101+
relative paths against every plausible base.
45102
"""
46103
if _billable_tests_enabled():
47104
return
48-
for arg in config.invocation_params.args:
49-
if str(arg).startswith("-"):
50-
continue
51-
# strip pytest's "::TestClass::test_name" node-id suffix
52-
candidate = pathlib.Path(str(arg).split("::")[0])
53-
if not candidate.is_absolute():
54-
candidate = (pathlib.Path(str(config.rootpath)) / candidate).resolve()
55-
else:
56-
candidate = candidate.resolve()
57-
if candidate == _INTEGRATION_DIR or _INTEGRATION_DIR in candidate.parents:
105+
for candidate in _iter_candidate_targets(config):
106+
if _targets_integration_dir(candidate, config):
58107
raise pytest.UsageError(
59-
f"Refusing to run {arg!r}: tests/integration provisions real, "
60-
f"billable cloud resources (AWS EKS/EC2). Set {_OPT_IN_VAR}=1 to "
61-
f"run them deliberately, e.g.\n"
62-
f" {_OPT_IN_VAR}=1 pytest {arg}"
108+
f"Refusing to run {candidate!r}: tests/integration provisions "
109+
f"real, billable cloud resources (AWS EKS/EC2). Set "
110+
f"{_OPT_IN_VAR}=1 to run them deliberately, e.g.\n"
111+
f" {_OPT_IN_VAR}=1 pytest {candidate}"
63112
)
64113

65114

tests/unit/test_billable_safety.py

Lines changed: 137 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -160,37 +160,31 @@ def test_bare_pytest_is_not_refused_by_the_guard(tmp_path):
160160
"""A bare `pytest` must still run. See #130.
161161
162162
This is the counterpart to the refusal tests, and it guards a trap that
163-
already sprang once. The guard originally inspected `config.args`, which
164-
reads like "what the user asked for" but is not: when no path is given on
165-
the command line, pytest populates `config.args` from `testpaths` in
166-
pyproject.toml. `testpaths` used to list `tests/integration`, so the moment
167-
the project's config actually took effect, a bare `pytest` matched the
168-
guard and aborted the entire suite with the billable-resources refusal.
169-
170-
Two independent changes fixed it: the guard now reads
171-
`config.invocation_params.args` (the real argv), and `testpaths` no longer
172-
names `tests/integration`. Either one alone is sufficient, which is why
173-
both are kept -- belt and braces.
174-
175-
Measured on this branch, undoing one is survivable and undoing both is not:
176-
177-
guard source testpaths bare pytest
178-
invocation_params.args ["tests"] ok
179-
invocation_params.args ["tests/unit","tests/integration"] ok
180-
config.args ["tests"] ok
181-
config.args ["tests/unit","tests/integration"] REFUSED
182-
183-
So this test is a guard on the *combination*, not on either edit alone.
184-
That is the honest scope: it is the last line of defence rather than the
185-
first, and it fails loudly the moment the suite becomes unrunnable.
163+
already sprang once. The guard reads `config.args` -- the *effective*
164+
target list -- which is what makes it resistant to indirect targeting
165+
(see `test_indirect_targeting_of_integration_is_refused`). The cost of
166+
reading it is that when no path is given, pytest fills `config.args` from
167+
`testpaths`. While `testpaths` named `tests/integration`, that made a bare
168+
`pytest` match the guard and abort the whole suite.
169+
170+
The fix is therefore in the config, not the guard: `testpaths` is `["tests"]`.
171+
That keeps `config.args` readable (so PYTEST_ADDOPTS and `-o testpaths=`
172+
cannot sneak past) while leaving a default run collectible.
173+
174+
Note this is the opposite resolution from the one first attempted here.
175+
Narrowing the guard to `invocation_params.args` also unblocked bare
176+
`pytest`, but it opened three real bypasses, because what the operator
177+
types is not what pytest collects. Reverting `testpaths` to name
178+
`tests/integration` will make this test fail -- correctly, since every
179+
default run would then be aimed at billable tests.
186180
"""
187181
result = _collect_integration(opt_in=False, tmp_home=tmp_path, target=NO_TARGET)
188182
combined = (result.stdout or "") + (result.stderr or "")
189183

190184
assert "Refusing to run" not in combined, (
191-
"A bare `pytest` was refused by the billable-resources guard. The guard "
192-
"is matching testpaths-derived arguments instead of what the operator "
193-
"typed (see #130).\n" + combined[-2000:]
185+
"A bare `pytest` was refused by the billable-resources guard, so the "
186+
"default suite cannot run at all. `testpaths` is probably naming "
187+
"tests/integration again (see #130).\n" + combined[-2000:]
194188
)
195189
assert result.returncode == 0, (
196190
f"A bare `pytest --collect-only` failed (exit {result.returncode}).\n"
@@ -201,6 +195,123 @@ def test_bare_pytest_is_not_refused_by_the_guard(tmp_path):
201195
)
202196

203197

198+
def _run_pytest(tmp_home, argv_extra, env_extra=None, cwd=None):
199+
"""Run pytest in a scrubbed subprocess with arbitrary argv and env.
200+
201+
Same isolation contract as `_collect_integration` (throwaway HOME, blocked
202+
sockets, credentials stripped) but without assuming the shape of the
203+
command, so indirect targeting routes can be exercised.
204+
"""
205+
env = dict(os.environ)
206+
env.pop(OPT_IN_VAR, None)
207+
env["HOME"] = str(tmp_home)
208+
env["USERPROFILE"] = str(tmp_home)
209+
sitecustomize = tmp_home / "sitecustomize.py"
210+
sitecustomize.write_text(
211+
"import socket\n"
212+
"def _deny(*a, **k):\n"
213+
" raise OSError('network disabled by test_billable_safety')\n"
214+
"socket.socket.connect = _deny\n"
215+
"socket.socket.connect_ex = _deny\n"
216+
"socket.create_connection = _deny\n",
217+
encoding="utf-8",
218+
)
219+
env["PYTHONPATH"] = os.pathsep.join(
220+
[str(tmp_home), env.get("PYTHONPATH", "")]
221+
).rstrip(os.pathsep)
222+
for leaked in (
223+
"AWS_ACCESS_KEY_ID",
224+
"AWS_SECRET_ACCESS_KEY",
225+
"AWS_SESSION_TOKEN",
226+
"AWS_PROFILE",
227+
"AZURE_CLIENT_SECRET",
228+
"GOOGLE_APPLICATION_CREDENTIALS",
229+
"LAMBDA_CLOUD_API_KEY",
230+
):
231+
env.pop(leaked, None)
232+
env.update(env_extra or {})
233+
return subprocess.run(
234+
[sys.executable, "-m", "pytest", "--collect-only", "-q", "-o", "addopts="]
235+
+ list(argv_extra),
236+
cwd=str(cwd or REPO_ROOT),
237+
env=env,
238+
capture_output=True,
239+
encoding="utf-8",
240+
errors="replace",
241+
timeout=300,
242+
)
243+
244+
245+
# Each entry is an *indirect* way to aim pytest at tests/integration -- one
246+
# that does not put the path anywhere in the typed argv, or that spells it
247+
# relative to something other than the repo root. Every one of these was a
248+
# working bypass at some point during #130 and is now refused. See the module
249+
# docstring of tests/conftest.py for why config.args is the right thing to read.
250+
_TARGET = "tests/integration/test_timeout_mechanism.py"
251+
INDIRECT_TARGETING = [
252+
pytest.param(
253+
{"argv_extra": ["-o", f"testpaths={_TARGET}"]},
254+
id="testpaths-overridden-on-command-line",
255+
),
256+
pytest.param(
257+
{"env_extra": {"PYTEST_ADDOPTS": _TARGET}},
258+
id="path-injected-through-PYTEST_ADDOPTS",
259+
),
260+
pytest.param(
261+
{"argv_extra": ["--pyargs", "tests.integration.test_timeout_mechanism"]},
262+
id="dotted-module-name-via-pyargs",
263+
),
264+
pytest.param(
265+
{
266+
"argv_extra": ["integration/test_timeout_mechanism.py"],
267+
"from_tests_dir": True,
268+
},
269+
id="path-relative-to-a-subdirectory-cwd",
270+
),
271+
]
272+
273+
274+
@pytest.mark.parametrize("case", INDIRECT_TARGETING)
275+
def test_indirect_targeting_of_integration_is_refused(case, tmp_path):
276+
"""Aiming pytest at tests/integration without typing the path is refused.
277+
278+
The guard originally read `config.invocation_params.args` -- literally what
279+
the operator typed. That is not the same as what pytest will collect:
280+
`testpaths`, `-o testpaths=` and `PYTEST_ADDOPTS` all feed `config.args`
281+
without appearing in the typed argv, and a path spelled relative to a
282+
subdirectory did not resolve against the repo root. Each of these collected
283+
billable modules while the guard believed nothing had been targeted.
284+
285+
Money is the reason this is parametrized rather than merged into one test:
286+
a single assertion that stops at the first failure would hide the others.
287+
"""
288+
case = dict(case)
289+
from_tests_dir = case.pop("from_tests_dir", False)
290+
result = _run_pytest(
291+
tmp_path,
292+
argv_extra=case.get("argv_extra", []),
293+
env_extra=case.get("env_extra"),
294+
cwd=(REPO_ROOT / "tests") if from_tests_dir else None,
295+
)
296+
combined = (result.stdout or "") + (result.stderr or "")
297+
298+
collected = [
299+
line
300+
for line in combined.splitlines()
301+
if "integration/test_timeout_mechanism" in line.replace("\\", "/")
302+
and "::" in line
303+
]
304+
assert not collected, (
305+
"Billable integration tests were collected through an indirect route "
306+
"the guard did not see:\n" + "\n".join(collected[:10])
307+
)
308+
assert "Refusing to run" in combined, (
309+
f"The guard did not refuse this run (exit {result.returncode}). It is "
310+
f"reading something narrower than the effective target list.\n"
311+
+ combined[-2000:]
312+
)
313+
314+
204315
def test_explicitly_targeting_integration_is_refused(tmp_path):
205316
"""Naming the directory or a file in it must fail loudly, not silently.
206317

0 commit comments

Comments
 (0)