Skip to content

feat(detector): surface detector-authored warnings in the result contract - #963

Merged
davidslater merged 5 commits into
mainfrom
ace/01M0ZM8X1QMJF2CGFV31MDFK93
Aug 26, 2026
Merged

feat(detector): surface detector-authored warnings in the result contract#963
davidslater merged 5 commits into
mainfrom
ace/01M0ZM8X1QMJF2CGFV31MDFK93

Conversation

@davidslater

Copy link
Copy Markdown
Collaborator

Created by GitHub Ace · View Session

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 ArtifactWarning when an artifact channel is present but cannot be inspected (prompt / agent_output / patch staged but empty or unreadable; comment-memory unreadable). It emits each as a ::warning:: annotation and then drops it. detection_result.json carries only the three booleans and reasons, 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:

"warnings": [
  { "field": "comment_memory", "code": "ERR_VALIDATION",
    "message": "Unable to read comment-memory directory at /tmp/…: permission denied" }
]
  • Populated by writeResult from Artifacts.Warnings, on a copy of the sink Result.
  • Written to both --output and --full-output, unlike reasons. 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.
  • Rendered by threat-detect conclude under a distinct ⚠️ block:
    ⚠️  Detector warnings (2) — artifact channels that could not be fully inspected. These do not affect the verdict.
         [1] field=comment_memory code=ERR_VALIDATION message=Unable to read comment-memory directory at /tmp/…
    
  • Fields sanitized on the way out, the same protection reasons get — a host-controlled path with an embedded CR cannot forge a workflow command (regression-tested).

Invariants

  • Warnings never affect the verdict or the exit code, including under 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 the CONTINUE_ON_WARNING issue.
  • The verdict remains sourced solely from the sink (TD-06a). The warnings assignment is unconditional, which is a security boundary rather than plumbing: the reporting tool exposes no warnings flag, but the engine holds a file-writing tool and knows THREAT_DETECTION_RESULT_FILE, so overwriting is what stops a model that wrote the sink directly from smuggling text into a published file. Tested.
  • Bounds enforced identically on write and read (≤20 entries; field/code non-empty ≤64 runes; message non-empty ≤2000 runes), so nothing accepted on write is rejected on read. Over-long values are clipped within the bound with an ellipsis — deliberately not conclude.go's truncateRunes, which appends "… (truncated)" past the requested length and would produce a file the detector rejects on read.
  • Additive and backward compatible: optional on parse (older results read fine), always emitted as [] (newer consumers can index without a null check).

Compatibility — verified against real consumers

I checked all three things that actually read detection_result.json:

Consumer Parsing Safe?
threat-detect conclude this repo's parser ✅ updated here
gh-aw conclude_threat_detection.sh (pinned v0.87.4) noneexecs threat-detect conclude
scripts/collect-detection-stats.sh jq has("prompt_injection") and … presence check ✅ tolerant of new fields

The detection artifact uploads detection_result.json by exact filename, not a glob, so detection_result_full.json still never leaves the runner.

Stale docs corrected

Checking the above turned up an inaccurate claim in README.md and CLAUDE.md: that the smokes "do not exercise threat-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.
  • conclude on a safe verdict + 2 warnings → ⚠️ block renders, conclusion=success, exit 0.
  • conclude on a pre-change result with no warnings key → parses, no spurious block, exit 0.
  • Strict mode (CONTINUE_ON_ERROR=false) + safe verdict + warnings → exit 0, proceeds. Warnings do not fail the run.
  • Strict mode + threat + warnings → exit 1, conclusion=failure. Threat still blocks.

make fmt lint build test test-scripts all clean.

Files

  • pkg/detector/result.goResultWarning, bounds, schema, Redacted preserves warnings.
  • pkg/artifacts/artifacts.go — export ErrCodeValidation; Code field on ArtifactWarning; MessageBody().
  • cmd/threat-detect/main.gobuildResultWarnings, clipRunes, non-mutating writeResult.
  • cmd/threat-detect/conclude.goreportWarnings.
  • 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.

GitHub Ace and others added 2 commits August 26, 2026 18:22
…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>
Copilot AI balanced review requested due to automatic review settings August 26, 2026 21:12
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>

Copilot AI 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.

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

Comment thread specs/threat-detection-spec.md Outdated
Comment on lines +199 to +204
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.
Comment thread README.md Outdated
Comment on lines +227 to +229
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>

Copy link
Copy Markdown
Collaborator Author

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 detected

Confirmed empirically before changing anything. Staging a non-empty prompt.txt and aw-test.patch at mode 000 and calling artifacts.Load:

warnings: 0
HasWarningForField(prompt)=false patch=false
PromptFileSize=25 PatchFileInfo="/tmp/permcheck/aw-test.patch (19 bytes, git-patch)"

Zero warnings, and the patch is described to the model as present and inspected. The failure chain is worse than the missing warning alone: BuildPromptAnalysis reads the prompt under if err == nil { rendered = string(data) }, so the read error is silently discarded and an unreadable prompt is analyzed as the empty string. And since HasWarningForField("patch") was false, #916's uninspectable was false too, so eligibility treated the channel as cleanly present rather than failing open.

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: readableFile opens the file and reads one byte, tolerating io.EOF so an empty-but-readable file is not misreported. Prompt and patch are probed; agent_output already used os.ReadFile and needed no change. An unreadable patch is routed through the existing unreadable list so it reuses the warning, the uninspectableNotice, and eligibility fail-open for free. An unreadable prompt yields no content, so it now counts toward AllPrimaryInputsMissing alongside missing and empty. Same fixture after:

warnings: 2
  field=prompt required=true msg=... could not be read (permission denied) ...
  field=patch  required=true msg=Unable to inspect 1 patch/bundle file(s) ...
HasWarningForField(prompt)=true patch=true
PatchFileInfo="Every patch/bundle file staged for this run was NOT analyzed ..."

Six regression tests added in pkg/artifacts/readability_test.go, plus two end-to-end ones. I verified they are genuine regression tests by neutering readableFile to return nil and confirming four of them fail; the two negative tests (readable and zero-byte inputs must not warn) correctly keep passing. They skip under root, where 0000 is advisory and the scenario cannot be built.

Strict mode — you're right, the invariant was overstated

main.go's !warnMode && arts.HasRequiredInputWarnings() returns config_error/exit 2 before writeResult, so "warnings never affect the exit code" was simply false for required inputs.

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 detection_result.json for a refused run is the exact fail-open outcome the refusal exists to prevent. So TD-10h now states what actually holds:

Warnings MUST NOT contribute to the verdict. No warning may cause a threat category to be reported true, and no warning may by itself cause the detector to exit with the threat-detected status.

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.

TestWriteResult_WarningsDoNotChangeExitCode was already scoped to the write path with a non-required field, so it stayed valid; I sharpened its comment to record the boundary. Two new tests pin the behavior in both directions: warn mode surfaces the unreadable prompt in the published result and still exits 0, and strict mode exits 2 (explicitly asserting not exitThreat), does not invoke the engine, and writes no result file.

make fmt lint build test test-scripts clean, plus go test -race ./....

Not addressed here

warnDegradedPromptAnalysis (missing/empty prompt-template.txt / prompt-import-tree.json) still writes its ::warning:: straight to stderr without recording into Artifacts.Warnings, so it remains absent from the result. Same class of dropped signal, but #954 scoped this to Artifacts.Warnings and my docs never claimed that case works — unlike the two above. Flagged for a follow-up rather than folded in.

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>

Copy link
Copy Markdown
Collaborator Author

Folded in the follow-up I'd flagged, in 47f1608warnDegradedPromptAnalysis was the last annotation-only path, so with it closed every degraded-inspection condition the detector knows about now reaches the published result.

It now returns its findings instead of only annotating, and they're merged into the set passed to writeResult. The annotation itself is unchanged, so job-log behavior is preserved — the result entry is additional, not a replacement.

The load-bearing detail is that these stay advisory. prompt-template.txt and prompt-import-tree.json are optional parts of the artifact contract. Had I recorded them with RequiredInput: true to match the other warning fields, TD-18c would promote them in strict mode and threat-detect would begin refusing every run of any host that never staged those files — silently converting an additive reporting change into a breaking one. So RequiredInput is false, pinned two ways: a unit test asserting HasRequiredInputWarnings() stays false, and an end-to-end test running strict mode against artifacts missing both files and requiring exit 0 with the engine still invoked.

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 conclude renders it under the ⚠️ block. As before I confirmed the new test is a real regression test by dropping the merge and watching it fail.

Two smaller things: the reported set is built as a fresh slice rather than appending onto arts.Warnings, whose backing array belongs to the loader; and these findings are deliberately not added to arts.Warnings, so eligibility is untouched — #916 already derives prompt-injection inspectability from the analysis fields directly, and feeding it a second time would double-count.

Spec and README updated: TD-10h now requires that any degraded-inspection condition reported as an annotation also be recorded in warnings, and that doing so must not change whether a finding counts as a required input.

make fmt lint build test test-scripts and go test -race ./... clean.

@davidslater
davidslater merged commit 326b962 into main Aug 26, 2026
9 checks passed
@davidslater
davidslater deleted the ace/01M0ZM8X1QMJF2CGFV31MDFK93 branch August 26, 2026 22:03
davidslater added a commit that referenced this pull request Aug 26, 2026
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>
davidslater added a commit that referenced this pull request Aug 26, 2026
#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>
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.

Surface detector-authored warnings in the result contract (warnings: [])

2 participants