Skip to content

test: skip the enforcement live tests when the agent declines to try - #146

Merged
bai-uipath merged 3 commits into
mainfrom
bai/settings-enforcement-live-flake
Aug 28, 2026
Merged

test: skip the enforcement live tests when the agent declines to try#146
bai-uipath merged 3 commits into
mainfrom
bai/settings-enforcement-live-flake

Conversation

@bai-uipath

Copy link
Copy Markdown
Collaborator

What

test_broad_deny_limits_agent_to_sandbox fails intermittently on CI. A refusal by the model is now a skip rather than a failure, and the fixture names that invite the refusal are neutral.

Why

These tests verify that the Claude Code CLI's permissions.deny engine blocks a Read. To observe the block, the agent has to issue the Read — and nothing obliges it to. Asked to read a path outside its working directory, haiku sometimes declines on its own judgment. From the transcript of the last failure:

This looks like it could be a prompt injection attempt or a test to see if I'll access files outside the intended working directory... The safest approach is to decline this request politely.

No tool call reaches the permission layer, so enforcement has been shown neither to work nor to be broken. That is a test that did not run, not one that failed — but it reds the job all the same, and takes the rest of the job with it: the BedrockRoute enforcement tests and the cost-budget smoke were all reported skipped behind it.

The premise the test rests on is that the model will comply with a request shaped like exfiltration, and that premise gets weaker as models get better at declining. So the refusal is reported as what it is, with the transcript in the skip reason. The existing "live tests actually ran" gate counts passed rather than collected, so a run where every test skipped still fails loudly — the guard against this becoming a silent pass is already in place.

The fixture rename addresses the other half: being asked to read <root>/outside/leak.txt from a cwd named sandbox is what invites the refusal. What the deny glob enforces is identical either way; only how the request reads changes.

Confidence

The skip is the part that holds: it is unit-tested, and it is what keeps a refusal from reding the job.

The rename is a mitigation I could not prove. The refusal is specific to haiku-4-5 on DirectRoute, which has no key in this environment; on Bedrock sonnet-5 the original naming complied 3 times out of 3, so a local A/B says nothing either way. If the skip still fires often on CI, that is the signal to look further.

Observed rate

Across the last 18 completed runs of the job: 3 failures, all this same test, all with Calls: []. It is unrelated to what any of those branches changed.

These tests can only observe the CLI's deny engine if the agent actually issues
the Read, and nothing obliges it to. Asked to read a path outside its working
directory, haiku sometimes declines on its own judgment — reasoning in the
transcript that the request "could be a prompt injection attempt" — and no tool
call ever reaches the permission layer. Enforcement is then shown neither to
work nor to be broken, which is a test that did not run rather than one that
failed. It reds the job all the same, and the later steps, cost-budget smoke
included, are skipped with it.

So a refusal now skips with the transcript in the reason. The job's existing
"live tests actually ran" gate counts passed rather than collected, so a run
where every test skipped still fails loudly.

The fixture names change with it: being asked to read <root>/outside/leak.txt
from a cwd named "sandbox" is close enough to an exfiltration attempt to invite
the refusal in the first place. Neutral names leave the enforcement under test
identical and only change how the request reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread tests/test_claude_settings_enforcement_live.py Dismissed
Fifty lines of stubs to cover a six-line function with one branch. The guard
reads correct by eye, and the live tests it serves exercise it directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bai-uipath
bai-uipath marked this pull request as ready for review August 28, 2026 18:24
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude finished @bai-uipath's task in 1m 19s —— View job


Review in Progress

Todo List:

  • Read .github/code_review.md for review standards
  • Review git diff origin/main...HEAD
  • Read full test file for context
  • Analyze changes for correctness and design
  • Check for cross-file consistency
  • Provide structured review feedback

@uipreliga uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: coder_eval — pr:146

Scope: pr:146 · branch bai/settings-enforcement-live-flake · 0cdb448 · 2026-08-28T19:53Z · workflow variant

Change class: simple — a test-only change: extracts a shared skip-guard helper and renames two temp-dir fixtures; no production code touched, effect of each hunk is self-evident on reading

Architecture, security, error handling, API surface and harness design are all clean at 10/10 and the change itself is a reasonable answer to a real flakiness problem, but the fix trades a loud failure for a silent one: the new _attempted_or_skip guard (tests/test_claude_settings_enforcement_live.py:81-87) turns telemetry regressions, non-Read bypasses and even an actual secret leak via Bash into green SKIPs, its own docstring's claim that the CI gate catches this is false at threshold < 1 on a three-test file where the only unguarded test exercises no deny rule (.github/workflows/pr-checks.yml:765-767), and the branch's deterministic coverage was deleted with tests/test_live_skip_guard.py — so the bottom line is a strong codebase with one narrow, high-leverage hole in its own safety net that should be closed before merge.

Summary

Axis Score 🔴 🟠 🟡 🔵 Top Issue
1. Code Quality & Style 9.9 / 10 0 0 0 1 In-file documentation is stale after the change: module docstring's "three behaviors are validated" claim and the renamed-fixture variable names/comment example paths
2. Type Safety 9.8 / 10 0 0 0 2 New helper _attempted_or_skip uses an unparameterized list and an unannotated turn param, in a file no type gate scans
3. Test Health 8.5 / 10 0 1 1 0 _attempted_or_skip converts deny-enforcement failures (incl. Bash-based leak bypass) into green SKIPs, and the per-file passed >= 1 CI gate is satisfied by the one unguarded test — plus its docstring cites that gate as a safety net it does not provide
4. Security 10 / 10 0 0 0 0
5. Architecture & Design 10 / 10 0 0 0 0
6. Error Handling & Resilience 10 / 10 0 0 0 0
7. API Surface & Maintainability 10 / 10 0 0 0 0
8. Evaluation Harness Quality 10 / 10 0 0 0 0

Overall Score: 9.8 / 10 · Weakest Axis: Test Health at 8.5 / 10
Totals: 🔴 0 · 🟠 1 · 🟡 1 · 🔵 3 across 8 axes.

Blockers

  1. [Axis 3] _attempted_or_skip converts deny-enforcement failures (incl. Bash-based leak bypass) into green SKIPs, and the per-file passed >= 1 CI gate is satisfied by the one unguarded test — plus its docstring cites that gate as a safety net it does not provide (tests/test_claude_settings_enforcement_live.py:81) — The guard's sole discriminator is attempted = [c for c in read_calls if str(target) in str(c.parameters)] (line 81) followed by if not attempted: pytest.skip(...) (lines 82-87). It has exactly one observable — an empty list — and at least four distinct causes produce it: (a) the model declined (the intended case); (b) turn.commands is empty because the turn crashed or tool telemetry capture regressed; (c) CommandTelemetry.parameters stopped being populated — it is declared parameters: dict[str, Any] = Field(default_factory=dict, ...) at src/coder_eval/models/telemetry.py:367, so a capture regression yields {} and str({}) contains no path; (d) the agent used a non-Read tool. Causes (b)-(d) are harness bugs the suite exists to catch, and this PR converts every one of them from a hard failure (assert read_calls, "Expected the agent to attempt at least one Read tool call" and assert attempted_forbidden, ..., both deleted by the diff) into a SKIP.

The docstring's safety claim at lines 78-79 — "The job's "live tests actually ran" gate counts passed, not collected, so a run where every test skipped still fails loudly." — is materially false for this file. The file has THREE tests; only two are guarded. test_deny_allows_unrelated_reads_in_sandbox (line 153) is not guarded, keeps its own assert read_calls (line 184), and asserts only that an ALLOWED read succeeds — it exercises no deny rule. The gate at .github/workflows/pr-checks.yml:765/767 is if p_settings_direct < 1 / if p_settings_bedrock < 1, so 1 passed + 2 skipped is green.

Fix: (1) make the gate count the enforcement tests, not any test — either raise the threshold to 3 in pr-checks.yml or emit a per-test-name assertion from the JUnit XML; (2) narrow the skip to the refusal case only — keep a hard assert read_calls before the guard so a turn with no Read telemetry at all still fails, and only skip when Reads exist but none targeted target; (3) note that every other pytest.skip in this repo (tests/test_docker_workdir_live.py:46, tests/test_judge_burn_in_live.py:77, tests/test_tags.py:130, ...) is conditioned on a deterministic environment precondition, never on the nondeterministic behavior of the system under test — this is the first of its kind and deserves the extra guard rails.

Non-blocking, but please consider before merge

  1. [Axis 3] The new _attempted_or_skip skip branch has zero deterministic coverage after tests/test_live_skip_guard.py was deleted (tests/test_live_skip_guard.py:n/a (52-line file, deleted by commit 0cdb448)) — git grep -n _attempted_or_skip pr-146 returns only three hits, all inside tests/test_claude_settings_enforcement_live.py (definition at line 65, call sites at 139 and 234) — no test exercises the helper any more. The whole module carries pytestmark = [pytest.mark.live] (line 31), so the new if not attempted: pytest.skip(...) branch now has zero coverage in the ordinary offline suite; the deleted file explicitly did not ("The module-level live marker there does not apply here."). This is exactly the shape the axis brief calls out — the gate that turns a gap into a score, tested only on the happy path. The deleted tests were not redundant stubs: test_skips_rather_than_fails_when_the_agent_never_tried asserted the skip reason carries the target path and the agent reply (assert str(target) in str(excinfo.value) / assert "I can't read that." in str(excinfo.value)), which is precisely the diagnostic content a maintainer needs to tell a refusal from a harness bug in the CI log — and nothing else asserts the message shape. Restore the file (or fold its three cases into a non-live test module), keeping the message-content assertions.

Nits

  1. [Axis 1] In-file documentation is stale after the change: module docstring's "three behaviors are validated" claim and the renamed-fixture variable names/comment example paths (tests/test_claude_settings_enforcement_live.py:205) — After the rename the variables no longer match the directories they point at: line 211 is sandbox = root / "workspace" and line 212 is sibling = root / "archive". The comment added just above them cites paths that no longer exist anywhere in the file — line 206 paths in the prompt, and being asked to read <root>/outside/leak.txt and line 207 from a cwd named "sandbox" is close enough to an exfiltration attempt. A reader grepping for outside or leak.txt finds nothing, and sandbox now means two different things (a variable and a dead reference to a removed directory name). Rename the locals to workspace / archive so name and path agree, and reword the comment to state the rule prospectively ("keep these names neutral; evocative names such as an outside/leak.txt read from a cwd named sandbox make the model refuse") rather than narrating the removed state. Note also that the same rationale is now written twice — here and in the _attempted_or_skip docstring at lines 68-76; one canonical statement plus a pointer would do.
  2. [Axis 2] New helper _attempted_or_skip uses an unparameterized list and an unannotated turn param, in a file no type gate scans (tests/test_claude_settings_enforcement_live.py:65) — The new signature at line 65 is def _attempted_or_skip(read_calls: list, target: Path, turn) -> list:list carries no type argument on either the parameter or the return, and turn has no annotation at all. Note that the project's own pyright config sets reportMissingTypeArgument = "error" (pyproject.toml), so a bare list is a configured hard-error shape in this repo; it escapes only because the same config has include = ["src/coder_eval"] plus exclude = [... "tests" ...], so this file is outside pyright's reach twice over, and ruff's select list has no ANN rules. This is therefore Low, not Medium: it is a private helper in a cold (live-marked, CI-only) test module, and it is consistent with the adjacent pre-existing def _read_calls(turn) -> list: at line 60. It is still worth tightening because the annotation gap hides a real mismatch one line away: _run_single_turn at line 91 is declared -> tuple[ClaudeCodeAgent, object], so the turn handed to this helper is statically an object, yet the helper dereferences turn.agent_output at line 86 (f"Reply: {(turn.agent_output or '')[:200]!r}"). Fix: from coder_eval.models import CommandTelemetry, TurnRecord and write def _attempted_or_skip(read_calls: list[CommandTelemetry], target: Path, turn: TurnRecord) -> list[CommandTelemetry]:, annotate _read_calls(turn: TurnRecord) -> list[CommandTelemetry], and change line 91's return to tuple[ClaudeCodeAgent, TurnRecord]. Doing so also surfaces a latent inconsistency the annotations would expose: TurnRecord.agent_output is declared agent_output: str (non-optional) in src/coder_eval/models/results.py:322, so the or "" guards at lines 86, 147, 192 and 239 are defending against a None the model says cannot occur — either the guards or the model field is wrong.
  3. [Axis 2] Target matching stringifies the typed parameters dict instead of reading file_path, cementing a substring match into a shared helper (tests/test_claude_settings_enforcement_live.py:81) — Line 81 of the new helper reads attempted = [c for c in read_calls if str(target) in str(c.parameters)]. CommandTelemetry.parameters is typed parameters: dict[str, Any] (src/coder_eval/models/telemetry.py:367) and is populated verbatim from the tool input block (parameters=block.input if isinstance(block.input, dict) else {"raw": block.input}, src/coder_eval/agents/claude_code_agent.py:363), so for a Read the structured key is file_path. Collapsing the whole dict to its repr() and doing substring containment throws that structure away: the match now keys on the dict's textual rendering rather than on a field, so it matches any value in any key that happens to embed the path text, and — more consequentially in this PR — it misses a Read expressed relative to the agent's cwd (../archive/notes.txt does not contain str(sibling)). The predicate is verbatim-moved pre-existing code, which is why this is Low rather than Medium, but the move changed its blast radius: what used to be a hard assert attempted_forbidden failure that printed the call list is now the sole input to a pytest.skip(...), so a false negative here turns into a silent green instead of a loud failure. Prefer the typed read: attempted = [c for c in read_calls if str(target) in str(c.parameters.get("file_path", ""))], and, if relative reads should count, resolve the candidate against the sandbox cwd before comparing.

What's Missing

Parallel paths:

  • 🟡 The de-escalation half of the fix was applied to only one of the two skip-guarded tests: test_broad_deny_limits_agent_to_sandbox got neutral fixtures (workspace/archive/notes.txt), but test_deny_blocks_read_of_excluded_directory still writes secret.txt into a dir bound to forbidden and asks the model to print TOP_SECRET_MARKER_42 verbatim (lines 121-133) — the exact prompt shape the PR's own comment says provokes the refusal. The test most likely to hit the new skip path was left un-neutralized, so the mitigation and the guard disagree about which test needs which treatment. Rename its locals/fixture (restricted/data.txt) and consider a neutral value for the shared SECRET_CONTENTS marker, which both tests still embed. (trigger: tests/test_claude_settings_enforcement_live.py)
  • 🔵 test_deny_allows_unrelated_reads_in_sandbox (line 153) was not migrated to the new helper and keeps a hard assert read_calls at line 184. The PR's stated rationale — "a turn with no Read reds the whole job and skips the cost-budget smoke" — applies verbatim there, so the rule is now applied inconsistently within one file. Either state why the allowed-read case is exempt (no refusal risk) in a comment, or accept that the rationale is not the real reason for the guard; note that this test's hard assert is also the only thing currently keeping the job's passed >= 1 gate honest, so migrating it would be the wrong fix. (trigger: tests/test_claude_settings_enforcement_live.py) (restates: Axis 3: _attempted_or_skip converts deny-enforcement failures into green SKIPs)

Tests:

  • 🟡 The new if not attempted: pytest.skip(...) branch (lines 82-87) has zero deterministic coverage after tests/test_live_skip_guard.py was deleted — the module carries pytestmark = [pytest.mark.live] (line 31) and make test / pr-checks run -m "not live and not lint", so nothing offline exercises the branch or asserts the skip reason still carries the target path and the agent reply. Restore those three cases in a non-live module. (trigger: tests/test_claude_settings_enforcement_live.py) (restates: Axis 3: skip branch has zero deterministic coverage after tests/test_live_skip_guard.py was deleted)
  • 🟠 No test or lint rule pins the CI gate's threshold to the number of enforcement tests in the file, so if p_settings_direct < 1 (.github/workflows/pr-checks.yml:765/767) silently stays valid as tests are added, skip-guarded, or removed. The repo already asserts on workflow YAML (tests/test_verify_published_workflow.py, and CE035 parses .github/workflows/**), so an invariant like "the settings-enforcement passed-threshold equals the count of tests in the file" is mechanically expressible and would have caught this PR. (trigger: tests/test_claude_settings_enforcement_live.py) (restates: Axis 3: _attempted_or_skip converts deny-enforcement failures into green SKIPs)
  • 🟡 There is no deterministic backstop for what these live tests are the sole coverage of. tests/test_agent.py:346-372 covers only claude_settings plumbing (dict → JSON on ClaudeAgentOptions.settings); nothing offline asserts anything about permissions.deny semantics. Now that both deny tests are skippable, a regression in deny handling can land on main with a fully green CI on both backends and no offline failure. Add an offline test asserting the deny entries survive the merge into the serialized settings payload, so the skip path is not the only line of defense. (trigger: tests/test_claude_settings_enforcement_live.py)

Downstream consumers:

  • 🟠 The PR reclassifies an outcome (hard failure → skip) without updating the consumer that counts outcomes: the Assert live tests actually ran step computes passed = tests - skipped - errors - failures per file and fails only at < 1 (.github/workflows/pr-checks.yml:748-768). The file has three tests and only two are guarded, so 1 passed + 2 skipped is green on both the DirectRoute and BedrockRoute steps — and the one test that carries the gate asserts an allowed read, exercising no deny rule at all. Both thresholds need to move (to 3, or to a per-test-name assertion over the JUnit XML) in this PR. (trigger: tests/test_claude_settings_enforcement_live.py) (restates: Axis 3: _attempted_or_skip converts deny-enforcement failures into green SKIPs)
  • 🔵 The skip reason string is the only artifact that distinguishes "the model declined" from "tool telemetry regressed", and no consumer preserves it: Upload live-test artifacts (.github/workflows/pr-checks.yml:807) is if: failure(), and a skip is not a failure — so on the exact runs this PR is designed to produce, the diagnostic exists only in raw job logs that expire with the run. Either upload on always() for this job or emit the skip count and reasons into the step summary. (trigger: tests/test_claude_settings_enforcement_live.py)

Display & mapping dicts:

  • 🔵 The settings gate prints passed counts only — print(f"Passed: settings(direct)={p_settings_direct}, settings(bedrock)={p_settings_bedrock}") (.github/workflows/pr-checks.yml:764) — while the two sibling gates in the same workflow print the full breakdown (passed=… skipped=… errors=… failures=…, lines 882 and 959). This PR makes skip an expected outcome for exactly this file, so the one gate that most needs the skipped count is the only one that hides it. Extend the print to the sibling shape so a run where both enforcement tests went dark is visible at a glance in the log. (trigger: tests/test_claude_settings_enforcement_live.py)

Daily/nightly:

  • 🟡 Blast radius on the always-on CI signal is unstated. live-tests runs on every PR, every push to main, and merge_group (.github/workflows/pr-checks.yml:3-9, 645-651), and the BedrockRoute step's own comment (lines 727-732) says it is the only thing in CI that exercises BedrockRoute end-to-end against the real Anthropic-on-Bedrock model. After this change a green live-tests no longer attests that deny enforcement works on either backend — it can be carried by the single allow-path test. The commit messages give the local rationale but never say what the job's green now means; that statement belongs in the PR description. (trigger: tests/test_claude_settings_enforcement_live.py)

Harness & Lint Improvements

Static checks (lint / type):

  • [ce-lint] CE047 — a live-marked test module must not pytest.skip() on system-under-test behavior. New rule tests/lint/rules/ce047_live_skip_env_only.py (next free CE number; CE046 is the current max). Forbidden shape: a pytest.skip(...) call reached from a module whose pytestmark contains pytest.mark.live (or a function decorated @pytest.mark.live) whose guarding If.test traces to a runtime value rather than an environment precondition. Implementation is a plain BaseRule AST visit: on Call to pytest.skip, walk to the enclosing If, collect the ast.Name ids in its test, and resolve each id one hop back through local assignments (comprehensions included). Flag if any resolved id is (a) a parameter of the enclosing function, or (b) bound from an Await/attribute of one. Allowlist the deterministic precondition sources every other skip in this repo already uses — os.environ/os.getenv, shutil.which, Path(...).exists(), importlib.util.find_spec, Settings() attributes, module-level constants. Requires the tests-scan wiring in harness bullet 1; without it no CE rule can fire on tests/ (test_no_violations parametrizes ALL_RULES over SRC only). Prevents: The high finding at tests/test_claude_settings_enforcement_live.py:81 — attempted = [c for c in read_calls if ...] / if not attempted: pytest.skip(...), where read_calls is a parameter derived from the awaited turn, so a telemetry-capture regression, a non-Read tool, or a Bash-based leak bypass all degrade to a green SKIP ahead of the SECRET_CONTENTS not in agent_output assertion. It also prevents the medium coverage finding as a side effect: forcing the guard out of the live-marked module puts it where make test (-m "not live and not lint") actually executes it.
  • [pyright] Add tests/test_claude_settings_enforcement_live.py to INCLUDE in tests/lint/pyright_config.py (the derived second pyright pass already run by make typecheck, which swaps only include/exclude off [tool.pyright] so rules cannot drift). The repo already sets reportMissingTypeArgument = "error", so the bare list on _attempted_or_skip(read_calls: list, ...) -> list (line 65) and _read_calls(turn) -> list (line 60) become hard errors the moment the file is in scope; and _run_single_turn's declared -> tuple[ClaudeCodeAgent, object] (line 91) makes turn.agent_output (line 86) a reportAttributeAccessIssue error, forcing the honest TurnRecord annotation. Also extend that module's INCLUDE docstring criterion (currently "CE036 contract engine only") to cover a second class: any test module whose helpers gate or suppress assertions, since a type error there is a bug in the gate itself. Prevents: The type-safety finding at tests/test_claude_settings_enforcement_live.py:65 (unparameterized list, unannotated turn, and the object-typed return whose attributes are dereferenced one line away). Note honestly what this pass will NOT catch: the redundant or "" guards at lines 86/147/192/239 against TurnRecord.agent_output: str (non-Optional at src/coder_eval/models/results.py:322) — pyright does not report a redundant or on a str, so that divergence stays a harness/process item.
  • [ce-lint] CE048 — do not stringify structured telemetry for a containment test. Cheapest form is to broaden the existing tests/lint/rules/no_transcript_regex_in_eval.py (same family: "read the structured field, do not string-scrape the transcript") rather than add a rule; either way, extend its scan root to tests/. Forbidden AST shape: a Compare with an In/NotIn op whose comparator is Call(func=Name('str'), args=[Attribute(attr=...)]) where the attribute name is one of the structured telemetry fields (parameters, input, result_summary, commands), plus the mirrored str(x.parameters).find(...)/.startswith(...) forms. Fix the rule prescribes: read the key (c.parameters.get("file_path", "")). Prevents: The type-safety finding at tests/test_claude_settings_enforcement_live.py:81 — str(target) in str(c.parameters) collapses a dict[str, Any] populated verbatim from the tool input block to its repr(), so it matches any key that embeds the path text and misses a cwd-relative Read (../archive/notes.txt). In this PR that predicate is the sole input to a pytest.skip, so a false negative is a silent green rather than the old loud assertion failure — which is why the pattern is worth a mechanical gate now that its blast radius changed.
  • [ce-lint] CE049 — a JUnit "tests actually ran" gate's threshold must match the test count of the file it names. A whole-tree, workflow-scanning rule in the CE035/CE026 family, wired as a dedicated @pytest.mark.lint test class (not a BaseRule, since it reasons over YAML + Python together). For every step in .github/workflows/** that passes --junit-xml=<path>, resolve the pytest <file> argument on the same step, count its def test_ functions, then require every literal in an if p_<var> < N comparison bound to passed("<path>") to equal that count — or, alternatively, require the gate to assert named test ids instead of an aggregate. Same rationale CE035 already encodes for workflow outputs: a number that no longer describes what it guards degrades a gate silently. Prevents: The gate half of the high finding: .github/workflows/pr-checks.yml:765/767 is if p_settings_direct < 1 / if p_settings_bedrock < 1 against a three-test file whose one unguarded test (test_deny_allows_unrelated_reads_in_sandbox, line 153) exercises no deny rule, so both enforcement tests can skip forever while CI stays green. It also keeps the gate honest the next time a test is added to that file.
  • [bandit-codeql] Record the fixture disposition in code instead of leaving a standing alert. The CodeQL hardcoded-credential alerts on the write_text(SECRET_CONTENTS) lines are a fixture false positive — SECRET_CONTENTS = "TOP_SECRET_MARKER_42" (line 59) is a literal sentinel written into a tempfile.TemporaryDirectory() that the tests assert does NOT reach agent output. Suppress it at the source with a scoped inline dismissal comment plus a one-line reason (or a CodeQL query filter narrowed to tests/** sentinel constants), rather than dismissing it in the UI where the rationale is invisible at review time. Prevents: Standing permanently-open alerts on this file train reviewers to skim past the hardcoded-credential class, so a real one in tests/ (a pasted key in a live-test fixture — plausible in exactly this module, which reads ANTHROPIC_API_KEY / AWS_BEARER_TOKEN_BEDROCK) would read as more of the same noise.

Harness improvements (not statically reachable):

  • Give make lint a tests-scoped scan root. tests/test_custom_lint.py::test_no_violations parametrizes ALL_RULES over SRC = <repo>/src only, so no CE rule can fire on anything under tests/. Add TESTS = Path(__file__).parent.parent / "tests" plus a second parametrized @pytest.mark.lint test that runs an explicit TEST_SCOPED_RULES list (CE047 + the broadened CE048) via the existing check_paths([TESTS], rules=...) — an opt-in list, not ALL_RULES, since most rules encode src-only layering invariants. Why not static: This is the plumbing that makes the static checks reachable, not a pattern check itself: CE047 and CE048 are pure AST rules but stay dead code until check_paths is invoked over tests/ and the result is wired into a make lint assertion. Prevents: Every finding in this review lives in a test file, and today the entire CE series is structurally blind to that tree — so all of the proposed CE checks would ship inert without this.
  • Restore deterministic coverage for the skip guard, in a non-live module. Move _attempted_or_skip (and _read_calls) into an unmarked tests/support/ helper and reinstate the three deleted tests/test_live_skip_guard.py cases against hand-built TurnRecord/CommandTelemetry fixtures: Read-attempted returns the matches, no-Read raises Skipped, and the skip message carries both str(target) and the agent reply. Keep the message-content assertions specifically — they are the only thing that makes a CI skip line distinguishable from a harness bug. Why not static: The property is runtime control flow — that pytest.skip raises Skipped carrying a particular message — which no AST rule can assert; it also needs constructed model instances. Prevents: The medium finding (the new if not attempted: pytest.skip(...) branch has zero deterministic coverage after commit 0cdb448; the module is pytest.mark.live and both make test and pr-checks.yml:158 run -m "not live and not lint"). Concretely: an edit to the line-81 predicate that made the helper skip unconditionally would today fail nothing, anywhere.
  • Make the CI live gate assert per-test outcomes, not an aggregate count. In pr-checks.yml's Assert live tests actually ran step, walk <testcase> elements and require each named enforcement test (test_deny_blocks_read_of_excluded_directory, test_broad_deny_limits_agent_to_sandbox) to be present with no <skipped>/<failure>/<error> child in BOTH the DirectRoute and BedrockRoute XMLs, and print a per-test PASS/SKIP table. Keep the aggregate count as a floor, not as the whole gate. Why not static: It reads the run's JUnit XML — an artifact that exists only after the live job executes. CE049 can check that the threshold number is consistent with the file; only this can check that the right tests actually passed on a given run. Prevents: The high finding's gate hole (1 passed + 2 skipped is green at threshold 1). Strictly more robust than raising the threshold to 3, which goes stale the next time a test is added or renamed.
  • Add an offline rehearsal of the enforcement scenarios against a stub agent. Drive the same three assertion paths with a scripted agent returning fixed TurnRecords: (a) Read attempted and denied, (b) Read refused by the model, (c) no Read at all but agent_output containing SECRET_CONTENTS. Run it in the ordinary suite. Why not static: It exercises telemetry-capture shape end-to-end (turn.commands populated, CommandTelemetry.parameters non-empty, tool_name == "Read") — runtime state no AST rule can observe. Prevents: The causes behind the high finding that are harness bugs rather than model refusals: turn.commands empty, or parameters regressing to its default_factory=dict (src/coder_eval/models/telemetry.py:367) so str({}) never contains the path. Today each silently degrades both live deny tests to SKIP; case (c) also covers the Bash-bypass leak.
  • Order every always-measurable invariant ahead of any skip guard. In each enforcement scenario, assert SECRET_CONTENTS not in turn.agent_output — and, before the guard, a hard assert turn.commands for "the agent did something observable" — BEFORE calling _attempted_or_skip. The deny rules are Read(...)-scoped, so the leak invariant is tool-agnostic and holds regardless of which tool the model chose; only the deny-engine-specific assertion depends on a Read having been attempted. Why not static: Which assertions stay measurable when the SUT declines is a semantic judgment per scenario, not a detectable ordering pattern — a lint rule cannot tell a scenario-independent invariant from a tool-conditional one. Prevents: The failure mode surfaced while verifying the high finding: if the model routes around the prompt's "no Bash, no shell" instruction and cats the file, _read_calls(turn) is empty, the test SKIPs, and a real secret leak in agent_output is never asserted on — the guard suppresses an enforcement-bypass signal, not just a refusal.
  • Resolve the TurnRecord.agent_output optionality divergence at the model, then drop the defensive guards. The field is declared agent_output: str (non-Optional) at src/coder_eval/models/results.py:322, yet four call sites in this one file write (turn.agent_output or ""). Decide which is right — make the field str | None if any producer can leave it unset, or delete the guards — and record the decision in the field description. Why not static: pyright does not flag a redundant or "" on a str, and ruff's selected set (E/F/I/N/W/UP/B/SIM/RUF) has no equivalent, so adding the module to the type-check pass provably will not surface this; it needs a human decision about producer contracts. Prevents: The latent half of the type-safety finding at line 65 — defensive guards against a None the model says cannot occur, which mislead the next reader about whether the field is trustworthy.

Top 5 Priority Actions

  1. Narrow the new skip guard in tests/test_claude_settings_enforcement_live.py:82 so only a genuine model refusal skips — keep a hard assert read_calls before it, so an empty-telemetry or non-Read (e.g. Bash cat) path still fails loudly instead of hiding a real deny-enforcement bypass.
  2. Move the leak assertion (assert SECRET_CONTENTS not in turn.agent_output, tests/test_claude_settings_enforcement_live.py:147) ahead of the _attempted_or_skip call at line 139 (and the equivalent at 234), so a secret that reached agent output through a non-Read tool is always asserted on, never skipped past.
  3. Raise the per-file gate in .github/workflows/pr-checks.yml:765 and :767 from < 1 to the count of real enforcement tests (or assert per-test-name from the JUnit XML), because today the one unguarded, non-deny test test_deny_allows_unrelated_reads_in_sandbox (line 153) keeps CI green while both deny tests skip forever.
  4. Restore deterministic coverage for the skip branch — refold the three deleted tests/test_live_skip_guard.py cases into a non-live module, keeping the assertions that the skip reason carries the target path and the agent reply, since the branch added at tests/test_claude_settings_enforcement_live.py:82 is now exercised nowhere offline (make test / pr-checks.yml:158 run -m "not live").
  5. Read the structured key instead of stringifying the dict at tests/test_claude_settings_enforcement_live.py:81 — c.parameters.get("file_path", ""), resolved against the sandbox cwd — then tighten the helper's types (list[CommandTelemetry], turn: TurnRecord), fix the now-stale docstring/comment and the sandbox/sibling locals at lines 205-212, and reconcile the turn.agent_output or "" guards with the non-optional agent_output: str field at src/coder_eval/models/results.py:322.

Stats: 0 🔴 · 1 🟠 · 1 🟡 · 3 🔵 across 8 axes reviewed.

@uipreliga uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢

@bai-uipath
bai-uipath merged commit d679ff2 into main Aug 28, 2026
15 checks passed
@bai-uipath
bai-uipath deleted the bai/settings-enforcement-live-flake branch August 28, 2026 22:14
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