feat(detector): surface detector-authored warnings in the result contract - #963
Conversation
…ract
Adds an optional `warnings: []` array to the detection result, populated
by the detector from `Artifacts.Warnings`. Each entry is a
`{ field, code, message }` object bounded at 20 entries / 64-rune ident
fields / 2000-rune message.
Warnings are:
- detector-authored (never model-authored), so safe to publish
- written to **both** result files, unlike `reasons` (TD-10f)
- rendered by `conclude` under a `⚠️ ` block distinct from the verdict
and reasons blocks
- never allowed to affect the verdict or the exit code
This gives hosts a programmatic signal that the detector could not
inspect part of the artifact bundle; previously that condition was only
visible via `::warning::` annotations in the job log and was lost from
`detection_result.json`.
Additive and backward compatible: `warnings` is optional on read (older
results parse unchanged) and always emitted as an array on write (newer
consumers can index without a null check). Schema validation on both
write and read prevents a written result from being rejected on read.
Spec (TD-08, TD-10g, TD-20j) and README updated.
Closes #954
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
Follow-up hardening on the warnings work, plus a documentation fix found while verifying compatibility. Hardening: - writeResult no longer mutates the caller's Result; it copies before attaching warnings. The sink Result is parsed from a model-written file, so rewriting it in place made the caller's value diverge from what was reported. - The warnings override is now documented as a security boundary, not just plumbing: the reporting tool exposes no warnings flag, but the engine holds a file-writing tool and knows THREAT_DETECTION_RESULT_FILE, so the unconditional overwrite is what stops a model that wrote the sink directly from smuggling text into a published file. Covered by a new test. - Replaced the silent clipper with clipRunes, which appends an ellipsis *within* the bound. Documented why conclude.go's truncateRunes cannot be reused: it appends "… (truncated)" past the requested length, which for a schema bound enforced on read would emit a result the detector rejects when reading it back. - Fixed an under-count in the truncation diagnostic when an earlier entry had been skipped as structurally invalid, and added a diagnostic for skipped entries. Docs: Verified the three real consumers of detection_result.json tolerate the added field. gh-aw's conclude_threat_detection.sh (pinned v0.87.4) does no JSON parsing at all — it execs `threat-detect conclude` — and collect-detection-stats.sh uses a jq has() presence check. That means the claim in README/CLAUDE.md that "the smokes do not exercise threat-detect conclude" and "show no reasons" is stale: the script delegates, no --full-result-file is passed so the sibling is derived, and reasons (and now warnings) do render in smoke job logs. Corrected both, and noted that warnings — unlike reasons — are present in the published artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
main's #916 claimed TD-10g for structural eligibility; this branch had also claimed TD-10g for the warnings array. Renumber the warnings requirement to TD-10h, order it after main's block, and add a cross-reference: TD-10g treats an uninspectable channel as present so it cannot suppress a finding, TD-10h makes that same condition visible to the host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds structured detector-authored warnings for partially inspected artifacts.
Changes:
- Extends the result contract with bounded, validated warnings.
- Publishes warnings in both result files and renders them during conclusion.
- Adds documentation and regression tests.
Show a summary per file
| File | Description |
|---|---|
specs/threat-detection-spec.md |
Specifies warning behavior and bounds. |
README.md |
Documents warnings and consumers. |
CLAUDE.md |
Corrects smoke-workflow behavior. |
pkg/artifacts/artifacts.go |
Adds structured warning codes. |
pkg/detector/result.go |
Extends parsing and serialization. |
pkg/detector/result_warnings_test.go |
Tests warning validation. |
pkg/detector/result_file_test.go |
Updates round-trip fixtures. |
cmd/threat-detect/main.go |
Attaches loader warnings to results. |
cmd/threat-detect/warnings_test.go |
Tests warning conversion and output. |
cmd/threat-detect/conclude.go |
Renders warning diagnostics. |
cmd/threat-detect/conclude_warnings_test.go |
Tests conclusion behavior. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Balanced
| Warnings MUST NOT affect the verdict or the exit code. A warning says "the | ||
| detector could not inspect everything", not "a threat was found"; conflating | ||
| the two would reintroduce false positives that a partially degraded staging | ||
| step would produce indistinguishably from a genuine finding. Gating a run on | ||
| the presence of warnings is a host-level policy decision and MUST NOT be | ||
| performed by the detector. |
| A warning is recorded when an artifact channel is present but cannot be | ||
| inspected — for example, `HAS_PATCH=true` was set but no readable patch bundle | ||
| was found, or the `comment-memory` directory could not be listed. Without the |
Address two review findings on the warnings contract. Readability was never verified. Prompt presence used os.Stat via fileSize and patch eligibility used os.Stat plus Size(); neither opened the file. A non-empty file with no read permission therefore stated cleanly, was described to the model as present and inspected, and produced no ArtifactWarning -- while BuildPromptAnalysis discarded the eventual read error and analyzed an empty string. The result was a clean upload hiding a channel nobody read, which is the fail-open shape uninspectableNotice exists to prevent, and it made the documented unreadable-channel cases unreachable. Add readableFile (open plus a one-byte read, tolerating io.EOF so an empty file stays readable) and probe prompt and patch. An unreadable patch routes through the existing unreadable list, so it reuses the warning, the uninspectable notice, and eligibility fail-open. An unreadable prompt yields no content, so it counts toward AllPrimaryInputsMissing alongside missing and empty. agent_output already used os.ReadFile and needed no change. Also correct the unqualified claim that warnings never affect the exit code. TD-18c promotes a required-input warning to a configuration error in strict mode and refuses the run before a verdict exists, so the claim was false there. Scope the invariant to what actually holds -- a warning never sets a threat category or causes a threat exit -- and state the strict-mode promotion, including why no result is written for a refused run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
|
Thanks — both comments were correct, and the second one was a real bug rather than a docs mismatch. Fixed in 7ae5e2d. Unreadable prompt/patch were never detectedConfirmed empirically before changing anything. Staging a non-empty Zero warnings, and the patch is described to the model as present and inspected. The failure chain is worse than the missing warning alone: This also contradicted the code's own stated contract — the patch loop's comment claims a patch counts as present only if it is a "readable, non-empty regular file", which stat cannot establish. Fix: Six regression tests added in Strict mode — you're right, the invariant was overstated
I took the "scope the requirement" option rather than moving the early return. Writing a result on that path would mean emitting a verdict for analysis that never ran — a clean-looking
followed by an explicit carve-out for TD-18c promotion, noting that the two exit statuses are distinct, that the run is refused rather than concluded, and why no result is written. TD-10h also now requires that inspectability not be decided on file metadata alone — which is what ties the two comments together. README carried the same overstatement in two places; both corrected, and the "recorded when" list now includes the unreadable cases that actually work.
Not addressed here
|
warnDegradedPromptAnalysis wrote its finding straight to stderr as an annotation and recorded nothing, so a run whose trusted-vs-untrusted analysis was degraded published a result indistinguishable from a fully inspected one. That is the same dropped-signal gap the warnings array exists to close, and it was the last annotation-only path left. Return the findings and merge them into the set handed to writeResult, so they reach both result files. The annotation is unchanged, so job-log behavior is preserved; the result is additional rather than a replacement. Keep them advisory. prompt-template.txt and prompt-import-tree.json are optional parts of the artifact contract, so RequiredInput stays false: classifying them as required would let TD-18c promote them and make strict mode refuse every run of a host that never staged them, turning an additive reporting change into a breaking one. Pinned by a unit test and an end-to-end strict-mode test. Build the reported set as a fresh slice rather than appending onto arts.Warnings, whose backing array belongs to the loader. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
|
Folded in the follow-up I'd flagged, in 47f1608 — It now returns its findings instead of only annotating, and they're merged into the set passed to The load-bearing detail is that these stay advisory. Verified with the built binary. Warn mode publishes it: "warnings": [
{ "field": "prompt_analysis", "code": "ERR_VALIDATION",
"message": "Missing or unusable prompt analysis artifacts: aw-prompts/prompt-template.txt, aw-prompts/prompt-import-tree.json. ..." }
]strict mode exits 0 with no "refusing to run degraded" line, and Two smaller things: the reported set is built as a fresh slice rather than Spec and README updated: TD-10h now requires that any degraded-inspection condition reported as an annotation also be recorded in
|
Reconciles the GH_AW_DETECTION_CONTINUE_ON_WARNING gate with #963, which landed the detector-authored `warnings` array on main and changed warnDegradedPromptAnalysis to return its findings so they reach the result. The two touched the same function for different reasons, so the merged signature keeps both: it returns []artifacts.ArtifactWarning for reporting and takes `fatal` for the TD-18f annotation escalation. The call site gates on len(analysisWarnings) > 0 while still feeding them into the reported warnings. TD-10h said the detector MUST NOT gate a run on advisory warnings, which TD-18f would have contradicted. Narrowed to what it meant: the detector never gates of its own accord, only where a host explicitly selects that policy. TD-10h's "no result is written for a refused run" clause now covers both promotion paths, and promotion under TD-18f explicitly does not reclassify a finding as a required input. Drops TestRunKeepsDegradedPromptAnalysisAdvisoryInStrictErrorMode as a duplicate of #963's TestRun_DegradedPromptAnalysisDoesNotBlockStrictMode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
#963 landed the `warnings` array in the result contract, a readability probe in artifacts.Load, and a `Code` field on ArtifactWarning. Reconciled against this branch's per-input read reporting: - Analysis findings flow into the `reported` slice #963 builds, rather than being appended onto arts.Warnings (whose backing array belongs to the loader), so they reach `warnings` in both result files. - artifacts.NewWarning sets Code alongside Field and RequiredInput, keeping findings raised outside Load identical in shape to Load's own. - warnDegradedPromptAnalysis is replaced by promptAnalysisWarnings. Findings are now per artifact (`prompt_template`, `prompt_import_tree`, `prompt`) instead of one lumped `prompt_analysis` entry, so each names the file the host must fix while the optional aids stay advisory. - Load's new readability probe already reports an unreadable required input, so the analysis pass skips fields Load already recorded; one degraded input consumes one entry of the bounded warnings array. What remains uniquely here is the window Load cannot see. - The strict-mode test is repointed at a prompt that passes every load-time check and is blank when the analysis reads it — the deterministic case that only the analysis pass can catch, now that the loader owns the unreadable one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
Closes #954.
Adds an optional
warnings: []array to the detection result so partial-inspection failures are visible to hosts programmatically on the uploaded result — not only as::warning::annotations in the job log.Problem
The loader records an
ArtifactWarningwhen an artifact channel is present but cannot be inspected (prompt/agent_output/patchstaged but empty or unreadable;comment-memoryunreadable). It emits each as a::warning::annotation and then drops it.detection_result.jsoncarries only the three booleans andreasons, so a partially-inspectable bundle is indistinguishable from a fully-inspected clean one to any programmatic consumer: the detector analyzed less than the full artifact set, reported clean, and exited 0.Change
New optional field on
Result:writeResultfromArtifacts.Warnings, on a copy of the sinkResult.--outputand--full-output, unlikereasons. Warnings carry no model-authored text (fixed detector strings + host-controlled paths), so publishing them is safe — and the published artifact is exactly where the signal was missing.threat-detect concludeunder a distinct⚠️block:Invariants
GH_AW_DETECTION_CONTINUE_ON_ERROR=false. Conflating "could not inspect" with "found a threat" would reintroduce the false-positive mode feat(detector): enforce structural eligibility for threat verdicts #916 exists to reduce; gating is a host policy decision tracked separately in theCONTINUE_ON_WARNINGissue.THREAT_DETECTION_RESULT_FILE, so overwriting is what stops a model that wrote the sink directly from smuggling text into a published file. Tested.field/codenon-empty ≤64 runes;messagenon-empty ≤2000 runes), so nothing accepted on write is rejected on read. Over-long values are clipped within the bound with an ellipsis — deliberately notconclude.go'struncateRunes, which appends"… (truncated)"past the requested length and would produce a file the detector rejects on read.[](newer consumers can index without a null check).Compatibility — verified against real consumers
I checked all three things that actually read
detection_result.json:threat-detect concludeconclude_threat_detection.sh(pinned v0.87.4)execsthreat-detect concludescripts/collect-detection-stats.shjq has("prompt_injection") and …presence checkThe detection artifact uploads
detection_result.jsonby exact filename, not a glob, sodetection_result_full.jsonstill never leaves the runner.Stale docs corrected
Checking the above turned up an inaccurate claim in
README.mdandCLAUDE.md: that the smokes "do not exercisethreat-detect conclude" and so "show no reasons". At the pinned v0.87.4 SHA the script does no parsing — it delegates, passing no--full-result-file, so the sibling full result is derived and reasons (and now warnings) do render in smoke job logs. Corrected both, and noted that warnings — unlike reasons — are present in the published artifact.Verification
Beyond unit tests, I exercised the built binary end to end:
report-result→ sink round-trips with the new field.concludeon a safe verdict + 2 warnings →⚠️block renders,conclusion=success, exit 0.concludeon a pre-change result with nowarningskey → parses, no spurious block, exit 0.CONTINUE_ON_ERROR=false) + safe verdict + warnings → exit 0, proceeds. Warnings do not fail the run.conclusion=failure. Threat still blocks.make fmt lint build test test-scriptsall clean.Files
pkg/detector/result.go—ResultWarning, bounds, schema,Redactedpreserves warnings.pkg/artifacts/artifacts.go— exportErrCodeValidation;Codefield onArtifactWarning;MessageBody().cmd/threat-detect/main.go—buildResultWarnings,clipRunes, non-mutatingwriteResult.cmd/threat-detect/conclude.go—reportWarnings.specs/threat-detection-spec.md— TD-08 updated; new TD-10g and TD-20j.README.md,CLAUDE.md— "Warnings vs reasons" section; stale smoke-conclude claim corrected.