Harden the gate: close inline-suppression bypasses, add stub/sleep checks, block conflict markers - #29
Harden the gate: close inline-suppression bypasses, add stub/sleep checks, block conflict markers#29rxdt wants to merge 1 commit into
Conversation
6b0acdd to
0b50aff
Compare
| import ast | ||
|
|
||
|
|
||
| def placeholder_stub(node: ast.AST) -> str | None: |
There was a problem hiding this comment.
ruff already catches this.
and notimplemented already raises
There was a problem hiding this comment.
Correct on both counts. Removed.
Ruff does catch it — unnecessary-placeholder fires on a ... body, and pylint adds W2301. My earlier probe missed this because it had no docstring, so undocumented-public-function masked the real result. Re-probed with a proper docstring and ruff flags the stub directly.
And raise NotImplementedError announces itself at runtime, so the residual value was only for code never executed — not enough to justify a check.
Generated by Claude Code
| return None | ||
|
|
||
|
|
||
| def blocking_sleep_call(node: ast.AST) -> str | None: |
There was a problem hiding this comment.
why wouldnt we just add time.sleep() to FORBIDDEN .PATTERNS in pyproject.toml?
There was a problem hiding this comment.
No reason. Done — "time.sleep(" is now in FORBIDDEN.PATTERNS and the check is deleted.
It's a literal string, so a pattern costs zero code, zero tests and zero complexity. I wrote an AST check for something grep already handles.
This is the principle running through your whole review, and I've applied it to the rest: __import__ and the mock interaction attributes moved to patterns too.
Generated by Claude Code
| Under a strict type checker that branch can never be False, so it tests nothing and hides that | ||
| nobody read the signature. A bare annotation (`count: int`) is the only case judged, because | ||
| `int | None` is a union where the check really does discriminate. | ||
|
|
There was a problem hiding this comment.
- prove that ruff, pylint, pyright, etc. don't already catch this
- prove this is a useful check to add
There was a problem hiding this comment.
Not caught. Probe: def f(count: int) -> int: with a full docstring, containing if isinstance(count, int).
ruff: clean
pylint: clean
pyright: 0 diagnostics
semgrep: 0 findings (p/python + p/default, 1104 rules)
I re-ran this with a docstring present specifically so undocumented-public-function couldn't masquerade as a hit — that artifact is what made me wrong about placeholder_stub above.
Useful: under typeCheckingMode = "strict" that branch can never be False, so it tests nothing while reading as caution — and it means nobody read the signature. Unions are deliberately untouched, since int | None is a check that really discriminates.
Weakest part, stated plainly: it only fires on bare-name annotations. Sequence[int] or a quoted forward ref slips through. So it catches the common shape, not the general case.
Generated by Claude Code
| return None | ||
|
|
||
|
|
||
| def defensive_none_check(node: ast.AST) -> str | None: |
There was a problem hiding this comment.
- prove this is needed given ruff and pyright are present
- if it is needed, can it be combined with
redundant_isinstance_checkiffredundant_isinstance_checkis needed?
There was a problem hiding this comment.
Removed rather than combined.
It genuinely wasn't caught — probe with real statement bodies (not pass, which trips unnecessary-if and would have faked a hit): ruff clean, pylint clean, pyright 0 diagnostics.
But "uncovered" isn't the bar. It was a second function walking the same isinstance calls as redundant_isinstance_check to answer a narrower question, and I couldn't show it catching anything real here. Merging it would have meant one function doing two jobs; keeping it meant duplicating the walk. Neither earns its place, so it's gone and redundant_isinstance_check stands alone.
Generated by Claude Code
| if not isinstance(inner, ast.Compare): | ||
| continue | ||
| if len(inner.ops) != 1 or len(inner.comparators) != 1: | ||
| continue | ||
| if not isinstance(inner.ops[0], ast.Is | ast.IsNot): | ||
| continue | ||
| against = inner.comparators[0] | ||
| if not isinstance(against, ast.Constant) or against.value is not None: | ||
| continue | ||
| if isinstance(inner.left, ast.Name) and inner.left.id in declared: | ||
| return f"'{inner.left.id}' is typed '{declared[inner.left.id]}' and is never None" |
There was a problem hiding this comment.
sloppy to have a list if/continue
There was a problem hiding this comment.
Agreed, fixed. Five stacked continue guards became one named condition:
for inner in ast.walk(node):
if not isinstance(inner, ast.Compare) or not isinstance(inner.left, ast.Name):
continue
against = inner.comparators[0] if len(inner.comparators) == 1 else None
guards_none = (
len(inner.ops) == 1
and isinstance(inner.ops[0], ast.Is | ast.IsNot)
and isinstance(against, ast.Constant)
and against.value is None
)
if guards_none and inner.left.id in declared:
return ...One continue for "not the right shape of node", then a single boolean that says what's being looked for. The ladder was me converting helper calls into guards when I inlined, which traded one kind of noise for another.
Generated by Claude Code
| return None | ||
|
|
||
|
|
||
| def legacy_shim(node: ast.AST) -> str | None: |
There was a problem hiding this comment.
do you have a test proving this works as it's supposed to?
There was a problem hiding this comment.
Yes. src/preferences/tests/test_preferences.py:
- Line 287 — a direct example: a function annotated
path: strcontainingif path is None, asserted to flag. - Lines 468–469 — a Hypothesis property that generates the annotation name and the parameter name, builds the function source, and asserts the check fires for any bare annotation. That is the part that proves it is not hardcoded to
str.
Both hold on the current tree; the gate run is green at 238 tests / 100.00% coverage.
The weakness the tests also pin: it only reads bare ast.Name annotations. path: str | None is a union where the guard is legitimate, so it is deliberately skipped, and path: "str" (string annotation) or Optional[str] are not parsed. So it is a narrow check, not a general nullability analysis.
Generated by Claude Code
|
|
||
|
|
||
| def message_chain(node: ast.AST) -> str | None: | ||
| """Flag four or more attribute hops, such as `cfg.server.pool.size`. |
There was a problem hiding this comment.
Fair question and I don't have a good answer, so I removed the check.
The honest version: a.b.c.d is a Law of Demeter argument — the caller reaches through objects it shouldn't know about, so a change three levels away breaks it. That's a design opinion, not a defect. When I ran it against this repo it found 0 occurrences, so I can't show it catching anything real, and the depth threshold was a number I picked. A check that fires on nothing and rests on my taste doesn't earn a slot in the walk.
Gone in 40ef4c7.
Generated by Claude Code
| return None | ||
|
|
||
| called = node.func.id | ||
| if called == "__import__": |
There was a problem hiding this comment.
why not put __import__ in FORBIDDEN PATTERNS?
There was a problem hiding this comment.
You're right — done, it's in FORBIDDEN.PATTERNS now.
__import__ is a literal string, so a substring scan of the staged diff catches every occurrence, including inside strings and comments where the AST walk never looked. Parsing it was strictly weaker.
What's left in the check is the half that genuinely needs the AST: getattr(obj, "p_" + key) — a computed attribute name. That can't be matched as a string, and getattr(obj, "count") with a literal name is left alone because ruff B009 already owns it.
Generated by Claude Code
| asking_how_it_was_called = ("assert_called", "assert_any_call", "assert_has_calls", "assert_not_called") | ||
| counting_calls = {"call_count", "call_args", "call_args_list", "await_count", "mock_calls"} |
There was a problem hiding this comment.
- if these are problematic, why not put them in FORBIDDEN.
- are they actually problematic?
There was a problem hiding this comment.
Yes, they're problematic, and yes they belong in FORBIDDEN.PATTERNS. Moved, and the check is deleted.
Why they're problematic: assert_called_once_with, .call_count, .mock_calls assert on how the code reached its answer — which collaborator, how many times, in what order. That pins the implementation, so a refactor that keeps behaviour identical still breaks the test, and the test passes even when the real output is wrong. Assert on the return value.
Why patterns and not a check: they're fixed attribute names. A substring scan of the staged diff catches them everywhere the AST walk did, plus the cases it missed. Same reasoning as time.sleep( and __import__.
Now in FORBIDDEN.PATTERNS: assert_called, assert_any_call, assert_has_calls, assert_not_called, .call_count, .mock_calls.
Generated by Claude Code
|
|
||
| - The quality minimum is code in `harness/gate.py` (forbidden to agents). Leave it unchallenged. | ||
| - Strengthen tests and coverage. Pass lint, type, and gate checks. | ||
| - Tests default to `tests/behaviour/`: a black box, input in and output asserted, nothing else. Do not assert on how a collaborator was called (`call_count`, `assert_called_with`). |
There was a problem hiding this comment.
we are not changing test directories
There was a problem hiding this comment.
Understood — reverted. AGENTS.md:14 is back to its original text, and I deleted the tests/behaviour/ and tests/integration/ directories I'd added along with the path-scoping (BEHAVIOUR_ONLY_CHECKS, is_integration_path, applicable_checks) that existed to serve them. Every check now runs on every file, as before.
Generated by Claude Code
| - The quality minimum is code in `harness/gate.py` (forbidden to agents). Leave it unchallenged. | ||
| - Strengthen tests and coverage. Pass lint, type, and gate checks. | ||
| - Tests default to `tests/behaviour/`: a black box, input in and output asserted, nothing else. Do not assert on how a collaborator was called (`call_count`, `assert_called_with`). | ||
| - Use `tests/integration/` only when a behavioural test genuinely cannot cover it. Putting a test there is a claim you are making, and it is reviewed. |
There was a problem hiding this comment.
Reverted — AGENTS.md:15 is back to the original line. I also dropped nosec from the suppression list there, since bandit isn't installed and listing a suppression for a tool that never runs implies coverage that doesn't exist.
Generated by Claude Code
rxdt
left a comment
There was a problem hiding this comment.
review comments and research
CONTAINMENT. RALPH_LOOP=1 was the only thing telling the gate an agent was driving, and an agent can simply not set it. harness/agent_detect.sh closes that: it reads agent env markers and walks process ancestry, and the git hooks force RALPH_LOOP=1 when it fires. CHECKS. Six structural checks in src/preferences/checks.py, each verified missing by running ruff, pylint, pyright and semgrep against a probe file: redundant_isinstance_check, defensive_none_check, legacy_shim, repeated_string_normalization, dynamic_reflection, long_docstring_sentence. Literal-string offenders are FORBIDDEN.PATTERNS entries instead -- a pattern is zero code and zero tests, and also catches occurrences inside strings and comments that an AST walk never sees. SPEED. pytest -n auto took the gate from 34.1s to 22.1s (pytest 26.0s -> 9.1s) with coverage verified still exact at 100.00%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf8DE1x6fbHhJWVwdyr72r
7b1a94a to
f7b6e4b
Compare
Closes gaps an agent could walk through today. Every addition here was verified against the configured stack first — anything ruff/pylint/pyright/semgrep/complexipy already catches was deliberately left out.
1. Suppression-bypass patterns (
FORBIDDEN.PATTERNS)The existing list covered
# noqaandtype: ignorebut left the security scanner reachable:nosemgrep,# nosec— the important ones. An agent could silence the gate's own semgrep pass inline, on the exact line that would have failed it.ruff: noqa,ruff: disable,ruff: ignore— file-level and range suppressions. Plain# noqadoes not substring-match these.fmt: off,fmt: skip,yapf: disable,complexipy: ignore— formatter and complexity defeats.--allow-empty,ralph_loop— empty-commit and loop kill-switch tampering.2. Merge-conflict markers
<<<<<<<and>>>>>>>. Verified gap: ruff catches these in.pyonly, and only incidentally asinvalid-syntax. A conflicted.json/.yml/.tomlpasses every configured check today.=======is deliberately omitted — it collides with ASCII rules in ordinary source.3. Two AST preference checks
placeholder_stub— a function whose entire body is...orraise NotImplementedError. The classic "signature looks finished, function does nothing" result, which currently passes lint, types, and coverage.@abstractmethodand@overload(bare or dotted) are exempt; a leading docstring does not rescue an otherwise-empty body.blocking_sleep_call—time.sleep(), used to paper over a race or a flaky test instead of waiting on the real condition. Consistent with the existingcontinue-in-whilefreeze-risk rule.4.
harness inforenderingForbidden entries now render one per line. Multi-word patterns such as
fmt: skipwere being wrapped mid-token by Rich, which broke both readability andtest_info_prints_all_harness_config.Verification
uv run harness gatepasses end to end: lint, pylint, complexipy, semgrep, pyright, and pytest at 100% coverage, 209 tests.preferences.pyremains fully covered, and the new checks were confirmed to fire on real samples with theabstractmethodexemption holding.Deliberately excluded as redundant
Empirically confirmed already covered, so not added: debug statements (ruff
T10catchesbreakpoint/pdb), mutable defaults (B006), bareexcept(BLE),eval(S307), hardcoded passwords (S105), private keys (semgrepp/secrets, blocking), and duplicate code (pylintR0801— active and gate-blocking, exit 8).🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf8DE1x6fbHhJWVwdyr72r
Generated by Claude Code