Skip to content

test(edge-cases): audit hash chain + PAT propagation — edge/property analysis of #1985 and #1979 - #2018

Merged
vybe merged 8 commits into
devfrom
test/edge-cases-2026-08-05-audit-pat
Aug 10, 2026
Merged

test(edge-cases): audit hash chain + PAT propagation — edge/property analysis of #1985 and #1979#2018
vybe merged 8 commits into
devfrom
test/edge-cases-2026-08-05-audit-pat

Conversation

@dolho

@dolho dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

/edge-cases pass over two features recently merged to dev. Tests only — no product code changed, per the skill's protocol: bugs are reported, fixing is a separate decision.

Findings (filed)

Issue Severity
Enabling the audit hash chain is in-memory only — a backend restart silently turns the integrity control off #2015 P2
A duplicated GITHUB_PAT line survives count=1 and wins under last-wins parsing — the agent keeps the revoked token while the rotation reports updated #2016 P3
A backslash in the token raises re.error mid-rotation; .env quote-escaping is write-only #2017 P3

Each is pinned by an xfail(strict=True) naming its issue, so the day one is fixed the corresponding test flips to passing and tells you.

What the analysis actually established

The chain works. A mutated hashed field, a deleted middle row and a reordered pair are all detected. What it does not cover is worth knowing before citing a green tick as evidence: _compute_hash hashes event_id/type/action/actor_id/target_id/timestamp/details/previous_hash, so actor_ip, actor_email, endpoint, source and mcp_key_id can be rewritten after the fact with the range still reporting verified — precisely the attribution fields an incident responder would lean on. Tail truncation is likewise undetectable (verification is over a caller-supplied range; the audit_log_no_delete trigger is what defends that, not the hash). Both are documented as boundaries, not filed as bugs.

The PAT round-trip holds for real input. The contract is stated once as a Hypothesis property — after a rotation the agent reads back exactly the new token under all three key names — with the oracle being a byte-faithful copy of the agent-server's own last-wins .env parser, because what the agent reads is the only definition of a successful rotation. It passes across 200 examples for every realistic single-line .env. The xfails are the inputs where it doesn't: a pre-existing duplicate line, and a token outside the PAT alphabet.

Two harness traps worth noting

Both were mine, caught before they could mislead:

  1. My first draft called _compute_hash off the module; it is a @staticmethod on the service. 23 red tests that said nothing about the product.
  2. The audit file used asyncio.get_event_loop().run_until_complete, which passes standalone and raises "no current event loop" the moment it is collected alongside the bug(security): audit-log verify returns valid:true with checked:0 — an unhashed chain reports as intact #1984 suite — that file uses asyncio.run, which closes the loop. Green locally, red in CI, purely on collection order. Now asyncio.run with the reason in the docstring.

Verification

84 passed, 6 xfailed
branch coverage: platform_audit_service 69%, github_pat_propagation_service 74%
  (misses are log() / accessors / the fleet loop — outside the target functions;
   verify_chain, _compute_hash and _patch_env_github_pat are fully covered)

Run together with the sibling suites (test_1984_*, test_1967_*, test_1574_*) rather than alone, for the reason above.

Not covered

Scoped to the two riskiest surfaces — hashing/verification and credential text-patching. #1981 (ask_trinity), #1980 (Codex auth), #1975/#1974 (scheduler audit + initiator) were not analyzed; they are mostly wiring, and the boundary bugs live where parsing and crypto do. Happy to do a second pass on those if useful.

…alysis

/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>

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The analysis itself is good and the protocol was followed exactly — three real bugs found, product code untouched, each one marked xfail(strict=True) with a findings reference per edge-cases/SKILL.md:96, issues filed the same minute. The round-trip property (patch the .env, then read it back through the agent's own parser) is the right question to ask of a credential rewrite, and it is what surfaced #2016 and #2017.

One blocking item, and it is narrow: the strict-xfail for #2015 can never fire. Everything else below is a merge-ordering note for the three fix PRs, not something I want changed here.

Blocking — the #2015 marker is inert, and so is its backstop

test_enabling_the_hash_chain_survives_a_restart asserts on a private attribute:

assert svc_b._hash_chain_enabled is True, (
    "hash chain silently reverted to disabled in a new process"
)

#2026 removes _hash_chain_enabled (the flag moves to system_settings, read through the new hash_chain_enabled property). So after that fix the assertion does not pass — it raises:

$ pytest ...::test_enabling_the_hash_chain_survives_a_restart --runxfail
E   AttributeError: 'PlatformAuditService' object has no attribute '_hash_chain_enabled'

xfail treats any failure as expected, so the marker stays XFAIL and keeps reporting "BUG: enabling the audit hash chain is in-memory only" against a codebase where that is no longer true. The whole point of strict=True is that it turns loud when the bug dies; here it goes quiet instead, permanently.

The backstop you wrote for exactly this case does not catch it either:

def test_the_enable_route_persists_nothing(self):
    """Pins the mechanism behind the xfail above, so the finding survives a
    refactor of the service..."""
    src = (_REPO / "src" / "backend" / "routers" / "audit_log.py").read_text()
    ...
    assert "set_setting" not in block and "system_settings" not in block, (
        "the enable route now persists — update or remove the xfail above"
    )

It reads routers/audit_log.py. #2026 put the write in the service — services/platform_audit_service.py:232, db.set_setting(self.HASH_CHAIN_SETTING, ...) — and left the router a thin passthrough. So the router still contains no set_setting, and this test passes with the fix in place. Verified on the merged tree: 1 passed.

Both alarms for #2015 are therefore dead after its own fix lands.

Suggested fix, small:

  • assert on the public seam (svc_b.hash_chain_enabled) rather than _hash_chain_enabled, so the marker flips to XPASS when the flag genuinely persists;
  • have the backstop check the service as well as the router, or assert against whatever function the route delegates to rather than a fixed filename.

This is the same shape as the guard misses already in docs/memory/learnings.md — a check that reads narrower than the thing it protects, and so reports safe. Worth one more entry given it is now the fourth instance.

Not blocking — merge ordering for #2024 / #2025 / #2026

Merging all four onto dev (composed resolution of the _patch_env_github_pat conflict) gives:

5 failed, 45 passed, 2 xfailed

[XPASS(strict)] BUG: `count=1` replaces only the FIRST GITHUB_PAT line...     (#2016)
[XPASS(strict)] BUG: the new line is used as an `re.sub` REPLACEMENT...  x3   (#2017)
FAILED  TestHashChainLifecycle::test_enabling_is_reflected_in_the_verdict

The four XPASS are the markers working as designed — that is the signal to delete them, and it belongs in the PR that fixes each bug, not here. Concretely: #2025 should drop the #2016 marker, #2024 the #2017 one.

The plain failure is a separate coupling to the same private attribute:

monkeypatch.setattr(svc, "_hash_chain_enabled", True, raising=False)

With raising=False this quietly binds a new attribute that nothing reads once #2026 lands, and verify_chain then reports hash_chain_enabled: False. Cleanest in #2026 alongside the property change, but flagging it here since it is this file.

Minor

The oracle's provenance note in test_pat_propagation_properties.py cites:

copied here from docker/base-image/agent_server/services/execution_env.parse_env_file

That module is not on dev — it is introduced by #2010 (fix/1999-env-ghost), still open. On dev the reader is docker/base-image/agent_server/routers/credentials.py:379-386, which is what #2024 and #2025 cite. The copied semantics are correct either way (I checked it line by line against credentials.py — strip, skip blank/#/no-=, partition("="), strip quotes, last write wins), so this is only the citation. Worth correcting because the docstring invites the reader to audit the copy against the original, and right now they cannot from this branch.

Confirmed clean

  • Scope: 3 files, tests plus a registry entry, no product code. Matches the protocol.
  • Green on its own branch: 46 passed, 6 xfailed.
  • tests/registry.json conflicts against dev but resolves cleanly by re-serializing the parsed JSON; noise, not a finding.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

trinity-ability and others added 2 commits August 8, 2026 13:42
Mechanical conflict resolution only:
- tests/registry.json: rebuilt from index stages (dev entries deduped, PR entry kept)
- docs/memory/*.md: union merge of two append-only additions

No code changes.
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>
@dolho

dolho commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@obasilakis — blocking item fixed. Both alarms for finding 1 were inert against the fix they guard, exactly as you described, and there was a third instance of the same shape next to them.

1. The strict xfail now asserts the public seam. svc_b.hash_chain_enabled instead of svc_b._hash_chain_enabled. The private attribute is what #2026 deletes, and xfail treats the resulting AttributeError as an expected failure exactly like the assertion failure it replaces — so the marker went quiet permanently instead of loud, which is the opposite of what strict=True is for.

2. The backstop now checks the setter the route delegates to, resolved through the import rather than a fixed filename, so moving the service doesn't silence it. Asserted over the AST (Call nodes named set_setting/set_system_setting), not a substring scan — #2026's own docstring explains the persistence it adds, so a text scan matches the prose and reports "still in-memory" with the write sitting right there. Same trap as #2024 in this batch.

3. test_enabling_is_reflected_in_the_verdict — the third failure you listed under merge ordering. It forced only _hash_chain_enabled; it now forces both seams with raising=False on each, so it reads the same answer before and after the flag moves rather than failing on the merged tree.

Verified on both trees, by actually merging fix/2015-hash-chain-persistence into this branch:

this branch alone (bug present):   28 passed, 1 xfailed        <- finding still reported
merged with #2026 (bug fixed):     [XPASS(strict)] BUG: enabling the audit hash chain is in-memory only ...
                                   FAILED ...::test_the_enable_route_persists_nothing
                                   test_enabling_is_reflected_in_the_verdict PASSED

Both alarms are loud on the merged tree and say to retire the finding — which is the behaviour the strict marker was supposed to have.

Merge ordering is unchanged: this still wants to land alongside #2024/#2025/#2026, and the XPASS is the intended signal to drop the marker in that sequence rather than something to pre-empt here.

@dolho
dolho requested a review from obasilakis August 10, 2026 08:54

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed. The blocking item is fixed, and fixed on both halves — which is what I was actually worried about.

test_enabling_the_hash_chain_survives_a_restart now asserts the public svc_b.hash_chain_enabled, so the marker flips to XPASS(strict) when #2026 lands instead of swallowing an AttributeError and reporting a dead bug as live forever. test_the_enable_route_persists_nothing now checks the setter the route delegates to, resolved through the import rather than a fixed filename, and does it over the AST — which it had to, for the same reason as #2024: #2026's docstring explains the persistence it adds, so a substring scan matches the prose. Forcing both seams in test_enabling_is_reflected_in_the_verdict with raising=False on each is a neat way to make one test read the same answer before and after the refactor.

One thing left, and it's merge ordering rather than anything you did wrong:

test_a_duplicated_pat_line_still_rotates is now XPASS(strict) — the suite is red

#2025 merged to dev on 2026-08-06, so _patch_env_github_pat no longer caps at count=1 and the #2016 marker passes. regression diff reports it as the one new failure introduced by HEAD.

I said in the first review that deleting each marker belongs to the PR that fixes the bug, not to this one. #2025 landed without deleting it, so it falls here by default — drop that xfail(strict=True) block (tests/unit/test_pat_propagation_properties.py:151-158) and keep the test as a plain assertion, since the behaviour it describes is now the correct one.

Leave the #2017 marker (test_a_backslash_in_the_token_does_not_raise) alone — #2024 is approved but not merged, so it's still a live bug on dev and the marker is doing its job. Whoever merges second between this and #2024 drops it.

Also worth correcting while you're in the file: the oracle's provenance note still cites docker/base-image/agent_server/services/execution_env.parse_env_file, which is introduced by #2010 and not on dev. The copied semantics are right either way; it's the citation that a reader can't follow from this branch.

Everything else from the first review stands as confirmed clean.

dolho and others added 4 commits August 10, 2026 13:53
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>
# Conflicts:
#	tests/registry.json
#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>

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed after the 10:53 push, and resolved the merge-ordering item obasilakis flagged.

Finding-2 marker (#2016) — retired by dolho at 10:53, correct: #2025 is on dev, so the behaviour is now the expected one.

Finding-1 marker (#2015) — I merged #2026 first (b0126f3), which makes this the second merge, so per obasilakis's rule the marker fell to this PR. Retired in a5e7d29: the restart test drops its xfail and keeps its assertion verbatim (it is now the named regression test for #2015), and test_the_enable_route_persists_nothing is inverted to ..._persists_durably — still an AST assertion, so a docstring describing persistence can't satisfy it. pytest tests/unit/test_audit_chain_edges.py → 29 passed against the merged tree.

#2017 marker — deliberately left in place. #2024 is not merged, so that bug is still live on dev.

Also resolved the tests/registry.json conflict by rebuilding from git stages (160 dev entries + this PR's 2, no duplicates).

@vybe
vybe dismissed obasilakis’s stale review August 10, 2026 16:18

Blocking item fixed at 10:53 (#2016 marker retired). The remaining merge-ordering item is resolved in a5e7d29: #2026 merged first, so the #2015 marker was retired here. #2017 marker left live.

@vybe
vybe enabled auto-merge (squash) August 10, 2026 16:21
@vybe
vybe merged commit a553f0a into dev Aug 10, 2026
19 of 20 checks passed
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.

4 participants