fix(github-pat): level every GITHUB_PAT line, not just the first (#2016) - #2025
Conversation
`_patch_env_github_pat` replaced the first matching line (`count=1`) while the agent's own `.env` reader is **last-wins**. On a file carrying a duplicate the rotation wrote the new token to line 1, the revoked token survived below it, and the agent went on authenticating with the revoked one — while `propagate_pat_to_all_agents` reported that agent as `updated`. Same silent-success failure #1967 exists to close, reached from the other side: there the rotation never reached the agent; here it reaches the agent and the agent ignores the result. The duplicate is not created here — this function appends only when the key is absent. It arrives from the paths that can also write the file: an agent editing its own `.env` (#1999), an operator appending over SSH or `docker exec`, or a restored/hand-merged file. Levelled, not de-duplicated. After this every copy carries the same value, so last-wins reads the right token whichever line it lands on, and the file keeps whatever structure the operator gave it. Removing lines would be a second behaviour change for no correctness gain. Every assertion goes through what the AGENT READS rather than what the file contains — that distinction is the bug itself: the file did contain the new token, on a line nothing read. The structural guard reads the `.sub()` call via `ast` rather than the source text, because this function's own docstring explains the bug and therefore contains the string `count=1` — a textual scan passes on the prose with the cap restored. My first draft did exactly that and failed here, which is the same trap the ledger records for #1871 and ent#314, and that ent#237's auth guard hit when `ast.dump` rendered a docstring. 21 tests; mutation-verified against both spellings of the cap (`count=1` and a positional third argument) — 15 fail on each. Closes #2016 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
Approving. The fix is right, the reasoning about why it is a bug is right, and the test that pins it is the best-constructed guard in this batch — I tried to defeat it two different ways and could not.
The core insight is the part worth keeping: the file did contain the new token, on a line nothing read. Asserting through the agent's own last-wins parser instead of through the file contents is what makes these tests mean anything, and the module docstring says so explicitly.
Verified
Fix behaves as described — 21 tests pass on the branch.
The guard survives mutation, both spellings. I reverted the fix two ways on the merged tree and re-ran test_the_substitution_is_not_capped_at_one:
restore `count=1` keyword -> FAIL (red) correct
restore positional `sub(x, y, 1)` -> FAIL (red) correct
The second one is the reason this guard is good. assert len(call.args) <= 2 catches the cap written positionally, which a keyword-only check would have missed — and that is exactly how a cap tends to come back during a refactor. The AST approach also sidesteps the trap your own docstring records:
this function's own docstring explains the bug and therefore contains the string
count=1, so a textual scan passes on the prose with the cap restored. My first draft did exactly that and failed here.
Worth flagging that the sibling PR #2024 did not clear that trap — its test_the_replacement_is_a_callable_not_a_string substring-matches "lambda" against inspect.getsource(...), and its own added comment contains the word lambda, so it passes with the fix reverted and the bug live. I have asked for it to adopt the shape you used here. Same function, same batch, one got it and one didn't — which is itself the argument for the AST form being the house pattern.
Level rather than de-duplicate is the right call and the docstring justifies it properly: after the change every copy carries the same value, so last-wins reads correctly whichever line it lands on, and the operator's file structure is preserved. Removing lines would be a second behaviour change buying nothing. The tests cover the shapes that actually occur — differently-formatted duplicate, commented duplicate left alone, lookalike keys (MY_GITHUB_PAT, GITHUB_PATX) untouched, each of the three #1574 mirror keys levelled independently.
agent_reads is byte-faithful to docker/base-image/agent_server/routers/credentials.py:379-386 — checked line by line (strip, skip blank/#/no-=, partition("="), key.strip(), value.strip().strip('"').strip("'"), last write wins).
Composition with #2024 — they conflict, and neither side is correct alone
Both PRs rewrite the same three lines. The conflict is:
<<<<<<< HEAD
out = line_re.sub(lambda _match: new_line, out, count=1)
=======
out = line_re.sub(new_line, out)
>>>>>>> origin/fix/2016-duplicate-pat-line
Correct resolution is the composition of both:
out = line_re.sub(lambda _match: new_line, out)Taking your side wholesale drops #2024's callable and reopens the backslash crash; taking theirs restores count=1 and reopens this bug. Your guard catches the second direction, which is the more dangerous one — so if you land first, CI will protect you. I confirmed the two fixes compose: with the line above, all of #2024's, #2025's and #2018's PAT tests behave as expected on the merged tree.
Whichever of you lands second: also delete the matching xfail(strict=True) from tests/unit/test_pat_propagation_properties.py (#2018) — for this PR that is test_a_duplicated_pat_line_still_rotates, which otherwise turns the suite red by passing.
Note, not blocking
tests/registry.json conflicts against dev. Re-serializing from parsed JSON rather than splicing the conflict markers avoids losing the separating comma. Noise, not a finding.
No credential values in the diff or the fixtures.
|
Resolve by running |
…-line # Conflicts: # tests/registry.json
|
Rebased onto The reasoning for levelling duplicates rather than de-duplicating them is the right call and worth keeping in the docstring: the agent's Merging on the existing approval. |
…-line-r # Conflicts: # tests/registry.json
…text The guard was `assert "lambda" in inspect.getsource(_patch_env_github_pat)`. That function's own comment opens with `# \`lambda _: new_line\`, NOT the string itself (#2017)`, so the assertion was satisfied by the prose: reverting the fix while keeping the comment — which is exactly what a bad resolution of the #2025 conflict does — restored `re.error: invalid group reference` in production and the guard still reported PASS. Every behavioural test above it stayed green too, because none of them run under a reverted implementation. Now it walks the AST of the function, finds the `.sub()` call, and asserts the replacement node is an `ast.Lambda`. Lambda ONLY, deliberately. My first AST draft allowed `Lambda | Name | Attribute` and the reverted bug passed again: its shape is `line_re.sub(new_line, out)`, whose replacement IS an `ast.Name`, and a Name is statically ambiguous — the str variable that caused #2017 and a named callable are the same node. The node set has to be chosen from what the BUG looks like, not from what correct code looks like. Mutation-verified 4/4 red, fixed code green: sub(new_line, out) -> FAIL (the actual #2017 revert) sub(f"{new_line}", out) -> FAIL sub("K=v", out) -> FAIL sub(new_line + "", out) -> FAIL fixed -> 29 passed Adds the learnings entry for the class (fourth instance), including the second trap: moving a guard to the AST does not by itself make it sound. Related to #2017 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's regression diff flagged this as a HEAD-only failure: [F] test_pat_propagation_properties.TestKnownGaps::test_a_duplicated_pat_line_still_rotates It is an XPASS(strict), not a broken test. The marker asserted finding 2 — that `count=1` replaced only the FIRST GITHUB_PAT line while the agent's .env parser is last-wins, so a duplicated line left the agent authenticating with the REVOKED token while the rotation reported `updated`. `31ba8d98` (#2016 via #2025) levels every occurrence on dev, so the strict marker went loud exactly as designed: the bug died and the alarm fired instead of going quiet. Retired to a plain regression test that now guards the fix instead of the defect, with the history in its docstring. The sibling markers stay: finding 3 (backslash parsed as an re.sub group reference) and finding 4 (writer escapes a quote the reader never unescapes) are still live on dev — their fixes are #2024 and #2030, both still open — and both correctly report XFAIL here. 47 passed, 5 xfailed (this file: 19 passed, 4 xfailed) Also merged latest dev. Related to #2015 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…analysis of #1985 and #1979 (#2018) * test(edge-cases): audit hash chain + PAT propagation edge/property analysis /edge-cases pass over the audit-chain (#1985) and PAT-rotation (#1979) features merged to dev. 84 passing cases plus 6 strict-xfails, each naming the issue it pins: - #2015 — enabling the audit hash chain is in-memory only, so a backend restart silently turns the integrity control off. #1985 made verify_chain honest about unhashed ranges; this is why ranges keep going unhashed. - #2016 — a duplicated GITHUB_PAT line survives `count=1` and wins under the agent's last-wins parser, so the agent keeps the revoked token while the rotation reports `updated`. - #2017 — a backslash in the token raises re.error (the line is an re.sub replacement), and the .env writer escapes a quote the reader never unescapes. The PAT contract is stated as a Hypothesis round-trip property against a copy of the agent's own .env parser, since what the agent reads back is the only definition of a successful rotation. It holds for every realistic single-line .env; the xfails are the inputs where it does not. Product code deliberately unchanged — findings are reported, fixing is a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(2018): make the #2015 alarms fire when the bug dies, not go quiet Both alarms for finding 1 were inert against the very fix they guard (#2026). 1. The strict xfail asserted `svc_b._hash_chain_enabled`. #2026 deletes that attribute when the flag moves to `system_settings`, and `xfail` treats the resulting AttributeError as an expected failure exactly like the assertion failure it replaces — so the marker would keep reporting 'BUG: enabling the audit hash chain is in-memory only' against a codebase where that is no longer true. `strict=True` exists to go loud when the bug dies; this went quiet, permanently. Now asserts the PUBLIC seam (`svc_b.hash_chain_enabled`), so the marker flips to XPASS(strict). 2. The backstop read only `routers/audit_log.py`. #2026 puts the write in the SERVICE (`db.set_setting`) and leaves the router a thin passthrough, so the router-only check passes with the fix in place. It now also inspects the setter the route delegates to, resolved through the import rather than a fixed filename — over the AST, because the fix's own docstring explains the persistence it adds and a substring scan matches that prose. 3. `test_enabling_is_reflected_in_the_verdict` forced only the private attribute, which is the third failure on the merged tree. It now forces both seams (`raising=False` on each), so it reads the same answer before and after the flag moves. Verified on both trees: this branch alone (bug present): 28 passed, 1 xfailed merged with #2026 (bug fixed): XPASS(strict) + backstop failure — both loud, telling you to retire the finding; the verdict test passes Related to #2015 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(2018): retire the finding-2 xfail — #2016 is fixed on dev CI's regression diff flagged this as a HEAD-only failure: [F] test_pat_propagation_properties.TestKnownGaps::test_a_duplicated_pat_line_still_rotates It is an XPASS(strict), not a broken test. The marker asserted finding 2 — that `count=1` replaced only the FIRST GITHUB_PAT line while the agent's .env parser is last-wins, so a duplicated line left the agent authenticating with the REVOKED token while the rotation reported `updated`. `31ba8d98` (#2016 via #2025) levels every occurrence on dev, so the strict marker went loud exactly as designed: the bug died and the alarm fired instead of going quiet. Retired to a plain regression test that now guards the fix instead of the defect, with the history in its docstring. The sibling markers stay: finding 3 (backslash parsed as an re.sub group reference) and finding 4 (writer escapes a quote the reader never unescapes) are still live on dev — their fixes are #2024 and #2030, both still open — and both correctly report XFAIL here. 47 passed, 5 xfailed (this file: 19 passed, 4 xfailed) Also merged latest dev. Related to #2015 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(2018): retire the #2015 xfail — #2026 landed #2026 merged to `dev` (b0126f3), so `enable_hash_chain` now persists to `system_settings` and the finding-1 marker would flip to XPASS(strict), turning `dev` red the moment this branch lands. Per obasilakis's review the marker belongs to whoever merges second; #2026 went first, so it falls here. - `test_enabling_the_hash_chain_survives_a_restart` drops the xfail and keeps its assertion verbatim — the behaviour it describes is now the correct one, so it becomes the named regression test for #2015. - `test_the_enable_route_persists_nothing` → `..._persists_durably`, with both halves inverted: the router still delegates, and the AST check now requires a real `set_setting` call in the service rather than forbidding one. Kept as an AST assertion for the original reason — a docstring describing persistence must not be able to satisfy it. The #2017 marker is deliberately left alone: #2024 is not merged, so that bug is still live on `dev` and the marker is doing its job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trinity-ability <trinity-ability@users.noreply.github.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
…text The guard was `assert "lambda" in inspect.getsource(_patch_env_github_pat)`. That function's own comment opens with `# \`lambda _: new_line\`, NOT the string itself (#2017)`, so the assertion was satisfied by the prose: reverting the fix while keeping the comment — which is exactly what a bad resolution of the #2025 conflict does — restored `re.error: invalid group reference` in production and the guard still reported PASS. Every behavioural test above it stayed green too, because none of them run under a reverted implementation. Now it walks the AST of the function, finds the `.sub()` call, and asserts the replacement node is an `ast.Lambda`. Lambda ONLY, deliberately. My first AST draft allowed `Lambda | Name | Attribute` and the reverted bug passed again: its shape is `line_re.sub(new_line, out)`, whose replacement IS an `ast.Name`, and a Name is statically ambiguous — the str variable that caused #2017 and a named callable are the same node. The node set has to be chosen from what the BUG looks like, not from what correct code looks like. Mutation-verified 4/4 red, fixed code green: sub(new_line, out) -> FAIL (the actual #2017 revert) sub(f"{new_line}", out) -> FAIL sub("K=v", out) -> FAIL sub(new_line + "", out) -> FAIL fixed -> 29 passed Adds the learnings entry for the class (fourth instance), including the second trap: moving a guard to the AST does not by itself make it sound. Related to #2017 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
_patch_env_github_patreplaced the first matching line (count=1) while the agent's own.envreader is last-wins. On a file carrying a duplicate, the rotation wrote the new token to line 1, the revoked token survived below it, and the agent kept authenticating with the revoked one — whilepropagate_pat_to_all_agentsreported that agent asupdated.Same silent-success failure #1967 exists to close, reached from the other side: there the rotation never reached the agent; here it reaches the agent and the agent ignores the result.
Where the duplicate comes from
Not from this function — it appends only when the key is absent, and substitutes when present. It arrives from the other writers of that file: an agent editing its own
.env(#1999), an operator appending over SSH ordocker exec, or a restored/hand-merged file. Uncommon, which is why this is P3; silent and credential-shaped, which is why it's worth the seven-character fix.Levelled, not de-duplicated
Both were on the table. After levelling, every copy carries the same value, so last-wins reads the right token whichever line it lands on — and the file keeps whatever structure the operator gave it. Removing lines would be a second behaviour change for no correctness gain, so I didn't.
Test plan
tests/unit/test_2016_duplicate_pat_line.py— 21 tests. Every assertion goes through what the agent reads, not what the file contains; that distinction is the bug, since the file did contain the new token, on a line nothing read.envtoo); 2/3/5 copies; each of the three Wire the agent GitHub PAT for theghCLI + REST API (not just git) #1574 mirrored keys independently, since each runs its own substitution and a cap left on one reopens the bug for that key alone; interleaved duplicates of different keys; the messy real-world duplicate shapes (indented, tabbed, unquoted, single-quoted, empty-value); a commented duplicate deliberately left alone; lookalike keys untouched; and the single-line path unchanged including idempotencecount=1and a positional third argument — 15 of 21 fail on eachOne test-quality note worth recording: the structural guard reads the
.sub()call viaast, not the source text. This function's own docstring explains the bug and therefore contains the stringcount=1, so a textual scan passes on the prose with the cap restored. My first draft did exactly that and failed here — the same trap the ledger records for #1871 and ent#314, and that ent#237's auth guard hit last week whenast.dumprendered a docstring.Merge order
line_re.sub(...). The resolution is to keep both edits:Whichever lands second takes that line. Kept as separate PRs because they are separate defects with separate tests; branched off
devrather than stacked so both get the full pytest/CodeQL matrix, which a feature-branch base would skip.Observed once, not reproduced
During adjacent-suite runs I saw a single collection error in
test_subscription_auto_switch_pingpong.py— a file this PR doesn't touch. It did not reproduce in five subsequent runs including three fixed seeds, and my new file passes when run directly alongside it. It looks like the pre-existing order-dependentsys.modulesinterference this suite already has, perturbed by adding a file. Flagging rather than omitting, since I can't prove it isn't mine.Closes #2016