Repository navigation
Tell the message of a failure in the report files as they are written - #487
Conversation
The message of a false test tells what its condition compared, cut short, and was masked as the test was checked. A secret a later step taught the masker was left in the report files as the part of it the cut kept, which the masking of the whole report no longer finds, as the terminal report did before it told the condition as it is shown. The failure now keeps the condition as the message ends with it, and the report files tell it anew from the condition as it is, masked with the masker of the run before its values are cut. With nothing learned since, the message is the one the failure was made with, which a test pins for each kind of failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
linyows
left a comment
There was a problem hiding this comment.
No blocking issues found. What I verified against b091a53:
- Suffix replacement:
Messageonly ever ends insummary.evaluateTestappends it last. The only later change is the"%d of %d iterations failed; the first: %s"prefix inrepeatStepResult(executor.go:305), and it copiessummarywith the struct. Because ofTrimSuffixplus theHasSuffixguard, a second occurrence of the summary earlier in the message doesn't matter, and an empty summary returns early. In practice the summary is never empty, becauseclauseSummaryreturns at leastc.Expr. - Failure kinds without a
summary: test error (an expr-langfile.Error, with no truncation in v1.17.8), type mismatch (%vof the whole value, not cut), action errors, and contract messages/violations (passed through from the action, not cut by probe). None of these messages is cut, sorep.Maskstill hides a late-learned secret in them. I checked type mismatch with a late-learned token: it comes out asxxx…<redacted> (string), not a boolean. Echo isn't cut either, except by the 1 MB template cap. The report package doesn't cutMessage: the GitHub summary cuts only at block boundaries. - Masker state: there is one workflow printer and one masker (
startReport), and every job'sJobContext.Printeris that same printer.Learnonly adds. So the masker at write time always knows at least what it knew at check time. Repeat and baseline tests usectx.Printer, the same printer.RunStandalone/embedded jobs write no report files.BuildReportstill passes nil, so its output is unchanged. - Late-learned secret, checked in a scratch worktree: in a repeated step's
N of M … the first:message and in a repeated job's test, the token is masked whole with no prefix left. For an||condition (no operands shown), the message is unchanged. - Concurrency: for async repeats,
summaryis set on the failure before it is published tocounter.FailureunderjCtx.repeat.mu.go test -race -run 'Repeat|Report'passes.
The one inline comment is a nit.
| // the part of it the cut kept, which no masker finds. Without a condition, | ||
| // or with no masker, it is Message. | ||
| func (f *StepFailure) reportMessage(m *mask.Masker) string { | ||
| if m == nil || f.clause == nil || f.summary == "" || !strings.HasSuffix(f.Message, f.summary) { |
There was a problem hiding this comment.
nit: when HasSuffix fails, this quietly returns the message that was cut at check time. That brings back the leak this PR fixes, and nothing tells you it happened. No current code path reaches it, because Message only ever gets a prefix after the summary is appended. But if a future change appends anything (a hint, a retry count, etc.), report files would quietly lose the masking again. Two options: keep the head instead (f.Message minus the summary, e.g. store prefix and make Message derived), or add a test that pins the suffix invariant for every place that rewrites Message.
The report files told the message anew only when Message still ended with the condition as the test was checked, and fell back to Message otherwise, which a later change that added to Message would have made it do with no test failing, and the leak back. The failure now keeps what precedes the condition, and the message of the report files is that and the condition told anew, always. A repeated step adds the count of its runs to both through prefixed, which a test of a secret learned after the check now covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
…ort-message-masking * origin/refactor/structure: Read an external action's parameters with actionrpc Send a header of nil as empty, and refuse one that is a list or a map Cut a long template output with truncate.String Back off a truncation no further than a rune can be long Count the methods of a .proto file in internal/protocoverage Write a number in a map of strings as its text, and drop HeaderToStringValue Cut a truncated string at the start of a rune Decode JSON with Parse, and keep both in decode.go Write a scalar as text in internal/scalar, for jsonutil and mapping Name the package that reads a response body internal/body Move the action protocol's generated code to internal/pb Move the packages only probe itself imports under internal/
#487 added the mask import beside expr in result.go and report_test.go; both keep it, with expr at its new path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
#484 masks what the false condition of a test compared as the terminal report shows it, so a secret a later step teaches the masker is hidden whole there. The report files still had the message of the failure as it was made when the test was checked: the condition on one line, its values cut at 200 characters and masked with what the masker knew then. A secret learned after that was left in
report.json, the JUnitmessageattribute and the Markdown report as the part of it the cut kept, whichReport.Maskcannot find.StepFailurenow keeps whatMessagehas before the condition (head), andreportMessageis that and the condition told anew from the clause it already keeps, masked with the masker of the run before the values are cut, asfailureTextdoes.writeReportsbuilds the report through an unexportedbuildReportthat takes the masker; the exportedBuildReportpasses none and keeps the message as it was, so its signature does not change. A repeated step adds2 of 3 iterations failed; the first: …to both the message and the head throughprefixed, and the test of a repeated job goes through the same path.With nothing learned since the check, the message is byte for byte the one the failure was made with.
TestBuildReport_KeepsTheMessageOfAFailurepins it for a false test with its operands shown, hidden and with a known secret cut short, one that cannot be evaluated, one that is not a boolean, an action's error and a broken contract, each alone, as the test of a repeated job and as a repeated step.TestWorkflow_Start_MasksWhatALaterStepTeachesInReportFileswrites the JSON, JUnit and Markdown reports of the leak in #484 and fails on the base branch withtk-012345678in all three.Other fields of the report files that hold cut text, left as they are:
echois cut by the template engine at 1,000,000 bytes, with the marker after it; a secret that straddles that point would leave its start.The request and the response under a failure are kept as data and masked whole as the files are written, so they are not cut.
🤖 Generated with Claude Code
https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42