test: sweep every keyword argument of every public function, and close what it found - #325
test: sweep every keyword argument of every public function, and close what it found#325lywinged wants to merge 2 commits into
Conversation
|
❔ Contributor Check: UNKNOWN
Automated check by AgenTrust Contributor Check. |
fb58955 to
3434601
Compare
|
Conflicted by my merge of #315, on Rebase onto Trial-merged before writing this: the only conflicted file is #327 is unaffected and still applies cleanly. |
5da4711 to
5faefe2
Compare
|
Rebased, Confirmed: One coupling the added-line comparison cannot reach, because it shares no line: #323 is #320's fix, and #320 is what this branch's two |
|
Merging #322 re-conflicted this on Please drop the I will write the changelog entries for this batch in one maintainer commit after they land, from the PR bodies. The cause is structural: every open PR here appends to one block, so each merge conflicts every other, and the rebase count scales with the queue rather than with any change. A fragment directory is the fix and I am taking it separately.
|
5faefe2 to
9387137
Compare
|
Done, The entry text is still in the PR body under Why, which is where you said you would take it from. The checklist line above it now overstates, since the file is no longer touched.
|
…ds (#335) verify_assertion() compared the duplicated binding fields with `record.get("subject") != data.get("subject")` and the same line for eat_profile. A comparison establishes that two reads agree, not that either exists, and two absences compare equal. A peer-produced assertion omitting data.subject, paired with a hash-matching record that also omitted subject, agreed by mutual absence and the function returned the parsed record as a successful binding. spec/content-marking-v1.md section 2 marks both fields required and section 6 says a conforming consumer checks both against the fetched record. This layer performs only the binding check and returns before any Trust Record signature or schema verification, and a caller is allowed to run it on its own, so it has to establish its own required shape rather than relying on a later verifier. Presence is now checked on both sides. An assertion missing a required field is ContentMarkingError, because a malformed assertion is the caller's own input and RecordMismatch would point the reader at whoever serves the URL, which is the reasoning test_an_int_no_longer_reports_a_record_mismatch already pins. A record missing one is RecordMismatch, because it matched the declared hash and that URL really is serving something that is not a conformant record. Two present values that disagree are unchanged. Regression coverage carries all six cases from the reproduction, including the two single-side controls that make the hole precisely mutual absence, plus a complete-pair control. The pair helper recomputes the hash, without which every case would fail at the digest check and pass for the wrong reason. Reported by @altrudev in #326, reproduced independently by @lywinged with the six-case matrix and the check against #325's head. Claude-Session: https://claude.ai/code/session_01X27MBo6tGVw1UmmchuGFnW Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e what it found The documented-exception sweep covered the first positional argument of each public function and declared `provenance.build_record` and `intent_bridge.verify_bridge` unsweepable, with a true reason: their arguments are keyword-only. agentrust-io#320 then arrived on `build_record`'s `issued_at`, a keyword argument that `int()` coerced before the guard saw it. An exemption whose reason is true is still an exemption, and the leaks were on the other kind of argument. The sweep now varies every parameter of every public function from a call that is valid in full, one parameter at a time, against the same junk matrix. Three things hold it honest: every parameter is accounted for by name, so a new one fails until it is swept or declared; every baseline is asserted to succeed before anything is varied, so a leak is from the varied parameter and not from a broken baseline; and one witness per function proves the call arrives. Two further checks come from agentrust-io#320's shape: what a producer accepts its own verifier has to accept, and a bare string or single object where an iterable is expected is refused rather than iterated. On main the sweep found eight parameter sites leaking undocumented exceptions and five behaviours that produced a wrong result with no exception at all. Closed here, each in its module's own pattern: - `build_record(publisher=<not a str>)` raised `TypeError` from the regex. - `build_record(attestation=False)` emitted `false` where the profile says `null`; truthiness stood where `is None` was meant. - `provenance.sign_record` let `rfc8785`'s errors and `key_to_jwk`'s `ValueError` escape; wrapped as `intent_bridge._jcs` already wraps. - `intent_bridge.sign_bridge(key=<not a key>)` raised `AttributeError`. The guard is `isinstance(key, Ed25519PrivateKey)`, which the package's two other signers already require through `key_to_jwk`; a duck-typed key no longer signs a bridge. - `content_marking.build_assertion` raised `TypeError` for a non-string `alg` and dropped a non-string or empty `anchor` on the floor. - `revocation.check_bundle(trusted_key_identifiers="sha256:...")` iterated the string as characters, matched no statement, and reported `verified`; a single JWK where a list was meant iterated as field names. Both refused now, and their elements are typed. - `sign.verify_record(trusted_bundle_keys="")` read an empty string as no keys. The eighth site is `issued_at` itself, which is agentrust-io#320's. Its cases are marked as strict expected failures against that issue, so its fix is what turns them green and this file fails the day the marker goes stale. Controls: reverting each fixed module fails the sweep (2, 2, 5, 10 and 4 tests respectively). 1485 passed, 1 skipped, 2 xfailed on 3.11 and 3.12. No wire format, schema or signature semantics change; every input that verified before verifies now. Signed-off-by: Louie Lu <48041247+lywinged@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9387137 to
47502b4
Compare
…ep was still reporting Rebased onto `c04d938`. agentrust-io#334 merged the reordering half of agentrust-io#320 and closed the issue, and the strict marker here did exactly what it is for: on the rebase, `test_no_keyword_argument_leaks_an_undocumented_exception[provenance.build_record.issued_at]` went `XPASS(strict)` and the file failed rather than going quietly green. The second case filed under the same entry did not flip. `test_what_a_producer_accepts_its_own_verifier_accepts` asks whether a producer emits anything its own verifier refuses, and for `issued_at` it still reported one value, `10000000000000000000`. That is the half agentrust-io#334 did not carry: `_check_structure` had no upper bound, so `build_record` accepted a timestamp `sign_record` cannot canonicalise and the caller met `rfc8785`'s `IntegerDomainError` rather than the `ProvenanceError` this module documents. Proposed on agentrust-io#320 on 09-10 and carried on agentrust-io#323, which is now superseded on everything else. `_check_structure` gains the bound, reading `JCS_SAFE_INTEGER` from `sign.py` rather than writing the number a third time, and `LEAKS_FILED` is empty. The comment above it records what the entry held and why it went, so the marker's two firings are not lost with it. `_check_structure` is shared, so this reaches `verify_record` too. Under the default freshness policy a record carrying such a timestamp was already refused as dated in the future, so no record that verified before is refused now. What changes is the caller who widens `max_future_skew_seconds` past the gap: that returned `rfc8785.IntegerDomainError` out of a function documented to raise `ProvenanceError`, and now returns `ProvenanceError`. A test pins it. The boundary is asserted against the canonicalizer rather than against a literal: each of the four cases checks `build_record` and `rfc8785.dumps` agree about whether the value exists, so the guard cannot drift away from the thing it is guarding. Verified in both directions. With the clause: 1547 passed, 1 skipped, and the sweep at 152 passed with nothing xfailed. With only the clause reverted: 4 fail, being the two out-of-range boundary cases, the verifier case, and the sweep's own producer-and-verifier case, which is the instrument that reported the gap now confirming it is closed. `ruff`, `mypy` and `tools/check_dashes.py` pass. No `CHANGELOG.md` hunk, per the instruction on 09-11. Signed-off-by: LouieLuNZ <48041247+lywinged@users.noreply.github.com>
47502b4 to
5ec62d5
Compare
lywinged
left a comment
There was a problem hiding this comment.
head 5ec62d5: ready for review, nothing outstanding on my side.
Rebased onto c04d938, now two commits: the sweep as you last saw it, and one that closes
the last thing it was still reporting.
The branch is out of the conflict loop. GitHub reports it blocked rather than dirty, and
all five checks are green on this exact head: gate, test (3.11), test (3.12),
Analyze (python) and CodeQL, finished 06:38 to 06:39Z. No conflicts, no red, no
CHANGELOG.md hunk to conflict with anything else in the queue. The only thing between it
and merge is a review, so it is approvable as it stands.
Two things are yours to decide and neither needs an answer before you read the rest. The
second commit adds a src/ change to a branch you have not reviewed yet, and if you would
rather it went as its own pull request against a fresh issue it is separable, being the
whole of the second commit. And #334 and this branch now sweep provenance.build_record
from two tables; I have left both rather than delete an entry that landed hours ago.
The strict marker is what turned up the rest, and not by firing. One of the two cases it
covered fired and the other did not, and the one that did not is the informative half.
What the marker did
LEAKS_FILED held one entry, ("provenance.build_record", "issued_at") against #320, and
it covered two cases. #334 merged the reordering half of #320 at 04:31Z.
On the rebase,
test_no_keyword_argument_leaks_an_undocumented_exception[provenance.build_record.issued_at]
went XPASS(strict) and the file failed, so the entry had to come out rather than going
quietly green.
test_what_a_producer_accepts_its_own_verifier_accepts[provenance.build_record.issued_at]
did not flip. It asks whether the producer emits anything its own verifier refuses, and for
issued_at it still reported exactly one value: 10000000000000000000.
The case that did not flip is the useful one. On 09-11 I told you that applying #323's
src/ diff to this head turns both markers into XPASS(strict); re-running it now,
that still holds. #334 carried one half of #320 and flips one. The only difference between
the two diffs is the safe-integer bound, so the case that did not flip names the half still
open, and nobody had to go looking for it.
The half that was open
On c04d938:
build_record(issued_at=2**60) -> built, issued_at=1152921504606846976
sign_record(that record) -> rfc8785.IntegerDomainError
_check_structure had no upper bound, so the producer accepted a timestamp it cannot
canonicalise, and the class the caller met was not the ProvenanceError the module
documents. #219 bounded every integer a schema declares, across five schemas plus the
undeclared cnf.jwk members, and the provenance record has no schema: its structural rules
are _check_structure. So #219's mechanism could not reach this field, which is why it is
the one that survived. I proposed the bound on #320 on 09-10 and @altrudev carried it on
#323; #334 landed without it and #320 is closed, so until this push the only place the case was
still written down was #323, which is superseded on everything else it carries.
The guard now carries it, reading JCS_SAFE_INTEGER from sign.py rather than writing the
number a third time. LEAKS_FILED is empty, and the comment above it records what the entry
held and what each of the two firings established, so the history does not leave with the
line.
The verifier side, stated because _check_structure is shared
Under the default freshness policy a record carrying such a timestamp was already refused,
as dated in the future, so no record that verified before is refused now. That is #219's own
argument about iat reaching the same field by the other route: a timestamp at the ceiling
dates a record to the year 285 million, and a verifier enforcing freshness rejects it before
canonicalization, so the collision never decides anything. What changes is a caller who
widens max_future_skew_seconds past the gap: that path returned
rfc8785.IntegerDomainError out of verify_record, which is documented to raise
ProvenanceError, and now returns ProvenanceError. A test pins it.
This is the same leak class the branch already closes on provenance.sign_record and
provenance.verify_record, where both now catch rfc8785.CanonicalizationError in the
shape intent_bridge._jcs already carried. issued_at is the one route into it that a
structural rule can close at the producer instead, which is better, because the caller is
told which field is wrong rather than that something somewhere in the record had no
canonical form.
Verified in both directions
- With the clause: 1547 passed, 1 skipped, and this file at 152 passed with nothing
xfailed, against 148 passed and 2 xfailed before. Two more cases than before, both from
#334's ownCALLSandREACHESentries forbuild_record; the two that were xfailed are
the same two cases, now passing.ruff,mypyandtools/check_dashes.pypass. - With only the clause reverted, everything else kept: 4 fail. The two out-of-range boundary
cases, the verifier case, and
test_what_a_producer_accepts_its_own_verifier_accepts[provenance.build_record.issued_at],
which is the instrument that reported the gap now confirming it is shut.
The boundary is asserted against the canonicalizer rather than against a literal. Each of
the four cases checks that build_record and rfc8785.dumps agree about whether the value
exists, so the guard cannot drift away from the thing it is guarding, which is the failure
that produced this in the first place.
The overlap with #334, in full
#334 put provenance.build_record into CALLS with issued_at as the varied argument.
This branch sweeps the same function over every parameter through KEYWORD_CALLS. Both
survive the rebase, so issued_at is swept twice. That costs a duplicated sweep and costs
nothing in coverage, and I left your entry rather than delete it: removing a test that
landed hours ago, inside a pull request about something else, is not this branch's call. A
note at the site says the same. Collapse it or keep it as you prefer.
One line of the PR body is stale, its account of the issued_at cases as strict expected
failures against #320, and I will correct it. My comment on 09-11 saying the branch is one
commit is stale too and stays, since it was true when it was written.
What this changes
tests/test_public_functions_raise_what_they_document.pyswept the first positional argument of every public function and declaredprovenance.build_recordandintent_bridge.verify_bridgeunsweepable, for a true reason: their arguments are keyword-only. #320 then arrived onbuild_record'sissued_at, a keyword argument thatint()coerced before the guard saw it. An exemption whose reason is true is still an exemption, and the leaks were on the other kind of argument.The sweep now varies every parameter of every public function, one at a time, from a call that is valid in full, against the same junk matrix. Three things hold it honest. Every parameter is accounted for by name, so a new one fails the coverage test until it is swept or declared with a reason. Every baseline is asserted to succeed before anything is varied, so a leak is from the varied parameter and not from a baseline that was already broken. One witness per function proves the call arrives. Two further checks come from the shape of #320: what a producer accepts, its own verifier has to accept; and a bare string or a single object where an iterable of them is expected is refused rather than iterated.
What it found on
main, and what changedEight parameter sites raised exceptions their modules do not document, and five behaviours produced a wrong result with no exception at all: the emitted
false, the droppedanchor, the string read as identifiers, the JWK read as a list, and the empty value read as no keys.build_record(publisher=123)raisedTypeErrorfrom the regex. NowProvenanceError.build_record(attestation=False)emittedfalsewherespec/server-provenance-v1.mdsaysnull, because truthiness stood whereis Nonewas meant. Now refused, on both sides: the shape check is shared, so a record already carryingfalse,0or""there is refused byverify_recordtoo. That is the one verification-side change here, and a test pins it with thenullcontrol beside it.provenance.sign_recordletrfc8785's errors andkey_to_jwk'sValueErrorescape. NowProvenanceError, with the wrapintent_bridge._jcsalready carries.provenance.verify_recordcarried the other half and now has the same wrap: the record a verifier reads is the untrusted document, and an out-of-range integer under a field the structural checks do not type, atoolsentry'sversionfor instance, left that function asrfc8785'sIntegerDomainError. Neither escape is one of the eight: the junk matrix never reaches canonicalisation, a record carrying such an integer does.intent_bridge.sign_bridge(key="a-string")raisedAttributeError. NowIntentBridgeError. The guard isisinstance(key, Ed25519PrivateKey), which the package's two other signers already require throughkey_to_jwk, so a duck-typed key object that used to sign a bridge no longer does; that is the one signing-side behaviour change here.content_marking.build_assertionraisedTypeErrorfor a non-stringalg, and a non-string or emptyanchorwas dropped silently, so the caller got an assertion with no anchor and no error. Both nowContentMarkingError.revocation.check_bundle(trusted_key_identifiers="sha256:...")iterated the string as characters, matched no statement, and reportedverified. A single JWK passed where a list was meant iterated as its field names. Both nowValueError, and the elements are typed.sign.verify_record(trusted_bundle_keys="")passedtrusted_bundle_keys or ()down, so an empty string, an empty object orFalsemeant no keys. OnlyNonemeans that now; the rest reach the check and are refused.The eighth site is
issued_atitself, which is #320's, and it is now closed here rather than filed. #334 merged the reordering half of #320 at 04:31Z on 09-12 and closed the issue. On the rebase ontoc04d938the marker did what it is for:test_no_keyword_argument_leaks_an_undocumented_exception[provenance.build_record.issued_at]wentXPASS(strict)and this file failed rather than going quietly green.The second case under the same entry did not flip.
test_what_a_producer_accepts_its_own_verifier_acceptsasks whether a producer emits anything its own verifier refuses, and forissued_atit still reported one value,10000000000000000000. That is the other half of #320:_check_structurehad no upper bound, sobuild_recordaccepted a timestampsign_recordcannot canonicalise and the caller metrfc8785'sIntegerDomainErrorrather than theProvenanceErrorthe module documents. #219 bounded every integer a schema declares, and the provenance record has no schema, which is why this field was the one left. The bound is in_check_structurenow, readingJCS_SAFE_INTEGERfromsign.py, andLEAKS_FILEDis empty._check_structureis shared, so the bound reachesverify_recordtoo. Under the default freshness policy such a record was already refused as dated in the future, so nothing that verified before is refused now; the case that changes is a caller who widensmax_future_skew_secondspast the gap, which returnedrfc8785's error out of a function documented to raiseProvenanceErrorand now returnsProvenanceError. Reverting only that clause fails four tests, one of them the producer-and-verifier case above, so the instrument that reported the gap is what confirms it is shut.One arithmetic note, because the count below does not move and the reason for it did. With all five modules reverted the report still shows 7 unfiled leaks even though
LEAKS_FILEDis now empty:mainalready carries #334's reordering, so revertingprovenance.pytomaindoes not bring the exception leak back, only the missing bound, which is a wrong result rather than a leak the report counts.tools/sweep_public_surface.pyprints, from the same tables, what each parameter accepts and which leaks are filed where, and--strictexits 1 on an unfiled leak. It is a report and not a test because the accepted column is a judgment: anowof0is accepted on purpose, anissued_atofTrueis accepted by accident. Two tests hold that it still runs over every function in both tables and that its exit goes red for a planted leak with nothing filed for it, a control that still fires the day the last real leak is fixed, so the sweep is read from a report rather than re-run by hand. Three of its accepted rows I looked at and left:revocationaccepts any string because astris aContainer[str]and substring membership still finds an identifier,revocation_bundleaccepts junk because the docstring promises it does not raise, andallow_embedded_keyis read by truthiness like any flag.Checked on the branch
Reverting each fixed module in turn fails the sweep: 5, 3, 12, 11 and 4 tests for
content_marking,intent_bridge,provenance,revocationandsign, and--strictexits 1 for the first four;sign's fix refuses with a documentedValueError, so it is a wrong result the tests catch and not a leak the report counts. With all five reverted at once the report shows 7 unfiled leaks. CI on5ec62d5is green on all five checks:gate,test (3.11),test (3.12),Analyze (python)andCodeQL. Locally, on the same head, 1547 passed and 1 skipped with nothing xfailed, against 1494 passed, 1 skipped and 2 xfailed on the previous base; the two xfails were theissued_atcases and are now passing tests.ruffpasses ontools/too, which CI does not lint. No wire format, schema or signature semantics change. Every record that verified before verifies now, except the falsyattestationstated above; checked by building every producer output the base accepts, on the base, and verifying each on this head.Type of change
None of the five. Reference implementation and its tests only.
Spec section
None changed.
spec/server-provenance-v1.md'snullforattestationis what one of the guards now enforces.Checklist
git commit -s)CHANGELOG.mdupdated (for any normative change): hunk deliberately dropped on 09-11 at the maintainer's request, so the queue stops conflicting on one block. The entry text is above under "What it found", which is where he said he would take it from.<!-- CHANGED: #NNN: description -->in spec text: not a breaking change