Skip to content

fix(tr-sig): report a malformed record instead of raising on it - #75

Draft
lywinged wants to merge 2 commits into
agentrust-io:mainfrom
lywinged:fix/tr-sig-private-key-raises
Draft

fix(tr-sig): report a malformed record instead of raising on it#75
lywinged wants to merge 2 commits into
agentrust-io:mainfrom
lywinged:fix/tr-sig-private-key-raises

Conversation

@lywinged

Copy link
Copy Markdown
Collaborator

Stacked on docs/error-code-registry, and opened as a draft until that lands. This branch contains that commit as well, so until it merges the diff here shows both: 12 files, not the 5 this change touches. GitHub re-scopes it automatically once the other PR is in. The TR-SIG-004 and TR-SIG-005 rows this change adds conditions back to are corrected there.

runner.run calls every module directly, with no try. A module that raises ends the run: the caller gets a traceback where a verdict belongs, and the record is neither passed nor failed. tr_sig did that in five ways.

$ python -m trace_tests.cli verify --record leaked.json
  File "src/trace_tests/runner.py", line 38, in run
    results["TR-SIG"] = tr_sig.check(trace, record, fmt, level)
  File "src/trace_tests/modules/tr_sig.py", line 126, in check
    findings.append(Finding(
TypeError: Finding.__init__() got an unexpected keyword argument 'rule'

The crash

Finding is a dataclass over code, status, message. There is no rule parameter, so that call has never produced a finding.

It is the check meant to catch a record that embeds its own private key, and it is the only such check in the suite. Nothing rejected the record earlier either: the packaged schema sets no additionalProperties on cnf.jwk and carries no guard against a d member. No test anywhere placed a d in a JWK, so the path had never run.

The other four

Same shape, one level down. trace.get("cnf", {}).get("jwk", {}) raises AttributeError whenever cnf or cnf.jwk is present but is not an object, and check_cmcp_runtime reads the same chain three deep. Measured before the change: a string, a number, null, a list or true in either position ends the run.

_jwk_of reads the JWK through isinstance guards and returns an empty dict when the record does not carry one as an object, which lands on the existing "kty is missing" finding. Both entry points use it, and its docstring says which of the two the outer guard is for: check_cmcp_runtime passes record["trace"] and can hand it anything, while check passes a trace that is already a dict and reads it directly elsewhere. That function is not hardened against a non-dict trace, and the helper does not pretend otherwise.

Which code the leak is reported under

TR-SIG-004, where docs/error-codes.md documents this condition, rather than TR-SIG-002, which the raising call named. TR-SIG-002 in this module belongs to check_cmcp_runtime and means a key that is not OKP/Ed25519; reusing it here would give one code two meanings across two formats.

A state this fix makes reachable for the first time

The leak branch returns early, so nothing else in the module runs. That path used to raise, so its outcome had never been observed. Making it reachable as it stood would publish a record carrying no signature verdict of any kind: not pass, not fail, not unverified. That is precisely the benign-omission reading UNVERIFIED exists to prevent, and one test in this suite already reads that finding with a bare next.

The branch now also emits TR-SIG-005 UNVERIFIED, "signature not checked; cnf.jwk carries private key material".

The tests

tests/test_modules_never_raise.py asserts that no module raises, and that none returns an empty finding list, over every top-level field replaced with each of nine junk values, every top-level field removed, the same two classes for cnf.jwk, and a JWK carrying d. The module list and each module's parameters are read from the signature rather than written down, so a module that gains a parameter is still exercised instead of dropping out quietly. False and 0 are among the junk values because a bare truthiness test reads them as absent, which is a different branch from a wrong type. Six of the seven modules already guarded their inputs with isinstance and pass unchanged; tr_sig was the outlier.

That parametrised test passes fmt="trace" throughout, so it never reaches check_cmcp_runtime. A separate case walks a cmcp envelope with the same junk values at trace, trace.cnf, trace.cnf.jwk and signature; without it, half of this change was hardened and untested.

Four further tests assert behaviour rather than the absence of an exception, because the generic test would pass if the checks were deleted outright:

  • a record embedding its own private key fails under TR-SIG-004;
  • the same record still reports whether its signature was checked;
  • runner.run completes on it at every level, which is the path the traceback came out of and which a module-only test would not cover;
  • no finding from any module repeats the key it found. report.py publishes every message into a JSON and an HTML artifact meant to be forwarded, so a message that quoted the offending value to be helpful would copy the private key into the thing the reader sends on. This runs over all seven modules rather than only the one that reports the leak, because a message anywhere could echo the value. The report itself carries only the record's digest, checked, so a message is the only place a key could be copied out.

Verified by reverting each change in turn; each has a test that fails without it and passes with it, including the cmcp read on its own. The report path was exercised end to end on the new status combination: JSON, HTML and the badge all build, level 0 reports 2 failures with 1 unverified, level 1 reports 4 with the unverified folded in, and no level is marked passed. The CLI now reports the record instead of ending the run.

TR-SIG  FAIL        TR-SIG-004: cnf.jwk must not contain private key material
                    ('d' member present in the JWK)
TR-SIG  UNVERIFIED  TR-SIG-005: signature not checked; cnf.jwk carries
                    private key material
Result: FAIL  (8 checks, 2 failure(s), 0 skipped)

Scope, and what is left

These tests mutate the fields of a record that is itself a dict. Three things nearby are not repaired here, each because it changes behaviour or decides what a new case reports, rather than fixing a call that never worked:

  • A record whose trace is not a dict at all is a hole one layer up. loader.extract_trace returns record["trace"] unchecked for cmcp-runtime, so an absent trace raises KeyError there, and a non-dict trace raises AttributeError in the first module to read it.
  • check_cmcp_runtime has no private-key check at all, so a cmcp runtime claim embedding d is detected by nothing.
  • A signature that fails verification is carried as a TR-SIG-005 finding whose message names TR-SIG-003, so the code in the forwarded report and the code in the message disagree.

217 passed, 5 xpassed. ruff reports six findings in tr_sig.py, all of them identical on main and none in the lines this changes.


Generated by Claude Code

…ules

Ten statements the documentation makes that the code does not do, across nine
pages. All live on main, none checked by anything.

Codes:

- TR-SIG-005 is carried by every signature finding tr_sig.py produces and is
  documented nowhere. It is the signature check outcome: the Ed25519 result, a
  signature that cannot be verified, or no signature at all, which is FAIL at
  Level 1 and above and UNVERIFIED at Level 0. UNVERIFIED is deliberately not
  SKIP, so an unsigned record cannot read as a benign omission.
- TR-SIG-004 was documented as private key material in cnf.jwk. It reports key
  type: kty missing, or not in {OKP, EC}. The private-key condition is not
  reported under this code, or any code, because the module raises before
  producing a finding for it. That is a module bug, fixed separately.
- TR-ANC-002 is documented in three files and named by no module, with two
  descriptions that disagree: docs/error-codes.md and docs/modules/tr-anc.md
  call it the https-scheme check, which tr_anc.py performs under TR-ANC-001,
  while docs/levels.md calls it a missing anchor.leaf_hash. Folded into
  TR-ANC-001. Adding a second ANC code instead is a behaviour change.
- TR-ANC-001 was documented as requiring a resolvable URI and as rejecting a
  placeholder value. Nothing resolves it, and no placeholder check exists
  anywhere under src/. It checks presence, string type, https scheme and a
  host. An earlier revision of this branch carried the placeholder claim
  forward; it is removed.
- TR-RTE-001 documented `sev-snp`, `tdx` and `opaque` as valid platforms. None
  is in _VALID_PLATFORMS, so a reader following the page produced a record
  TR-RTE-001 rejects, while eight of the ten registered platforms went
  unmentioned. The wrong list appeared in three pages, and docs/levels.md
  carried a sample record using one of them.
- TR-RTE-003 was documented as resolving the RIM URI and checking the manifest
  behind it. It checks that the string starts with https://, and nothing else.
- TR-ENV-004 was documented as a required-fields gate over the schema's full
  required set, positive case "all of: eat_profile, iat, subject, ...". It
  checks cnf.jwk.kty, and nothing else.
- TR-POL-002 lost track of `declared`, in four pages. Four values, not three.
- docs/modules.md summarises five of the seven modules as doing work they do
  not do: private key leak detection (the path that raises), RIM URI
  resolution, SCITT inclusion proof structure, builder URI, and required
  fields. Each row now says what its module checks.

Samples:

- docs/levels.md shows an anchor object in its minimum conformant Level 2
  record. schemas/trace-claim.json is additionalProperties: false and defines
  no anchor property. Measured: valid_level0.json validates, and the same
  record with the documented anchor block fails with "Additional properties are
  not allowed ('anchor' was unexpected)". anchor.leaf_hash appears nowhere else
  in the repository.

tests/test_docs_match_the_modules.py adds two guards.

The code set named by the modules must equal the code set with a row in
docs/error-codes.md. A row, not a mention: a code named in passing is not
documented, and accepting a mention would let the check be satisfied by prose
that tells a reader nothing. Every TR- code in this package lives under
modules/, so that scope is complete.

And every JSON sample under docs/ must agree with the packaged schema. Three
qualify today, all in docs/levels.md, but every .md is scanned rather than a
list kept by hand, because a hand-maintained list of what gets checked is the
same defect this exists to catch. The check also fails if it validated nothing,
so it cannot degrade to a pass over no work when the samples or the fence
change.

Both guards fail on main's documentation and pass on this one. Verified
individually: a code moved from a row into prose fails the first, a changed
fence fails the second, and each sample drift reintroduced alone fails alone.

What the guards do not do, stated in their docstrings. The code check matches
on codes named in module source, not on Finding.code: TR-SIG-003 appears only
inside a message string a TR-SIG-005 finding carries, so matching on
Finding.code would demand deleting a row that documents a real condition. And
neither guard can tell whether a row describes what its code reports, which was
six of the ten defects above; those were checked by reading the rows against
the module.

The sample guard drops `required` before validating, since the samples are
fragments, but leaves `if` and `not` intact. Stripping `required` from an `if`
makes it vacuously true and fires the matching `then` against records the
condition never meant to reach; the schema's `origin` rule does exactly that,
and an earlier draft of this guard reported two valid samples as broken. String
values containing an ellipsis are dropped before validation: a sample signature
written as eyJhbGciOiJFZERTQSJ9... is a reader's placeholder, not a claim about
the format.

Not touched: two sample CI transcripts under docs/tutorials/ quote output the
code no longer produces. Regenerating a transcript is a different kind of
change and they are left rather than edited by hand.

203 passed, 5 xpassed. ruff clean. No broken relative links, nav unchanged.
CI runs neither ruff nor mypy today.

Signed-off-by: LouieLuNZ <48041247+lywinged@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:UNKNOWN Contributor check flagged UNKNOWN risk label Aug 21, 2026
`runner.run` calls every module directly, with no `try`. A module that raises
ends the run: the caller gets a traceback where a verdict belongs, and the
record is neither passed nor failed. tr_sig did that in five ways.

    $ python -m trace_tests.cli verify --record leaked.json
      File "src/trace_tests/runner.py", line 38, in run
        results["TR-SIG"] = tr_sig.check(trace, record, fmt, level)
      File "src/trace_tests/modules/tr_sig.py", line 126, in check
        findings.append(Finding(
    TypeError: Finding.__init__() got an unexpected keyword argument 'rule'

`Finding` is a dataclass over `code, status, message`. There is no `rule`
parameter, so that call has never produced a finding. It is the check meant to
catch a record that embeds its own private key, and it is the only such check
in the suite. Nothing rejected the record earlier either: the packaged schema
sets no additionalProperties on cnf.jwk and carries no guard against a `d`
member. No test anywhere placed a `d` in a JWK.

The other four are the same shape one level down. `trace.get("cnf", {}).get(...)`
raises AttributeError whenever cnf or cnf.jwk is present but is not an object,
and check_cmcp_runtime reads the same chain three deep. Measured before the
change: a string, a number, null, a list or True in either position ends the
run. `_jwk_of` reads the JWK through isinstance guards and returns an empty
dict when the record does not carry one as an object. Both entry points use it,
and its docstring says which of the two the outer guard is for: cmcp passes
record["trace"] and can hand it anything, while check passes a trace that is
already a dict and reads it directly elsewhere, so that function is not
hardened against a non-dict trace and this helper does not pretend otherwise.

The leak is reported under TR-SIG-004, where docs/error-codes.md documents this
condition, rather than under TR-SIG-002, which the raising call named.
TR-SIG-002 in this module belongs to check_cmcp_runtime and means a key that is
not OKP/Ed25519; reusing it here would give one code two meanings across two
formats.

The leak branch returns early, so nothing else in the module runs. That path
used to raise, so its outcome was unreachable and no consumer had met it.
Making it reachable as it stood would publish a record carrying no signature
verdict of any kind: not pass, not fail, not unverified, which is the
benign-omission reading UNVERIFIED exists to prevent, and one test in this
suite already reads that finding with a bare `next`. The branch now also emits
TR-SIG-005 UNVERIFIED, "signature not checked; cnf.jwk carries private key
material".

tests/test_modules_never_raise.py asserts that no module raises, and that none
returns an empty finding list, over every top-level field replaced with each of
nine junk values, every top-level field removed, the same two classes for
cnf.jwk, and a JWK carrying `d`. False and 0 are among the junk values because
a bare truthiness test reads them as absent, which is a different branch from a
wrong type. The module list and each module's parameters are read from the
signature rather than written down, so a module that gains a parameter is still
exercised instead of dropping out quietly. Six of the seven modules already
guarded their inputs with isinstance and pass unchanged.

That parametrised test passes fmt="trace" throughout, so it never reaches
check_cmcp_runtime. A separate case walks a cmcp envelope with the same junk
values at trace, trace.cnf, trace.cnf.jwk and signature; without it, half of
this change was hardened and untested.

Four further tests assert behaviour rather than the absence of an exception,
because the generic test would pass if the checks were deleted outright: a
record embedding its own private key fails under TR-SIG-004; the same record
still reports whether its signature was checked; runner.run completes on it at
every level, which is the path the traceback came out of; and no finding from
any module repeats the key it found, since report.py publishes every message
into a JSON and an HTML artifact meant to be forwarded. That last one runs over
all seven modules rather than only the one that reports the leak, because a
message anywhere could echo the value. The report itself carries only the
record's digest, verified, so a message is the only place a key could be copied
out.

Verified by reverting each change in turn; each has a test that fails without
it and passes with it, including the cmcp read on its own. The report path was
exercised end to end on the new status combination: JSON, HTML and the badge
all build, level 0 reports 2 failures with 1 unverified, level 1 reports 4 with
the unverified folded in, and no level is marked passed.

    TR-SIG  FAIL        TR-SIG-004: cnf.jwk must not contain private key material
                        ('d' member present in the JWK)
    TR-SIG  UNVERIFIED  TR-SIG-005: signature not checked; cnf.jwk carries
                        private key material
    Result: FAIL  (8 checks, 2 failure(s), 0 skipped)

Scope, and what is left. loader.extract_trace returns record["trace"] unchecked
for cmcp-runtime, which reading it alone suggests is a third hole of this kind.
It is not one on the path the tool takes: load_record refuses a cmcp envelope
whose trace is not a dict before extract_trace runs, measured through the CLI on
an absent trace, a string and a list, each reported as an error rather than a
traceback. extract_trace is unexported and runner.run is its only caller, so
reaching it means assembling a record by hand and calling the runner without the
loader.

Two things nearby are real and are not fixed here, because each decides what a
new case reports rather than repairing a call that never worked.
check_cmcp_runtime has no private-key check at all, so a cmcp claim embedding
`d` is detected by nothing. And a signature that fails verification is carried
as a TR-SIG-005 finding whose message names TR-SIG-003, so the code in the
forwarded report and the code in the message disagree.

217 passed, 5 xpassed. ruff reports six findings in tr_sig.py, all identical on
main and none in the lines this changes.

Signed-off-by: LouieLuNZ <48041247+lywinged@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:UNKNOWN Contributor check flagged UNKNOWN risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant