Skip to content

Commit 643efaa

Browse files
davidslaterGitHub Ace
andauthored
fix(detection-stats): treat external as default and scope reported_reasons to it (#911)
* fix(detection-stats): treat external detector as default after gh-aw #54111 gh-aw PR #54111 flipped `features: gh-aw-detection` to enabled by default, so every compiled `.lock.yml` now carries the `Install threat-detect binary` step unless the workflow explicitly opts out. Validated against gh-aw main: 236/274 workflows with a `detection` job carry the marker; the other 38 all set `gh-aw-detection: false` in frontmatter. Update the daily stats collector to match: - Add a symmetric built-in path rollup (mirroring the external path rollup), so skipped / cancelled / in-progress detection jobs on a workflow whose other runs went through the built-in path are classified as built-in instead of leaking into the `unknown` bucket. - For `.lock.yml` runs with no evidence either way, default to external (the new gh-aw default) instead of `unknown`, so residual reliability failures on rarely-run workflows are still counted in the external rate. - Refresh the marker comment, the summary description, and `detection-stats-daily.md` so they document the new default. - Update the offline stub-API test to reflect that wf1's skipped / cancelled / in-progress runs are now built-in via the rollup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(detection-stats): scope reported_reasons to the external population The "Reasons reported by gh-aw" table counted every warning/failure comment in `[aw] Detection Runs`, including those for built-in-detector runs and (pre-classifier fix) indeterminate runs that never entered the external population every other rate on the report is measured over. That made the table incomparable with the "By workflow" and "Notable runs" sections below it. Bind an `$ext_ids` set from the external-detector population and filter `$reasons` against it before grouping. Update the summary blurb and the workflow-doc bullet to spell out the new scoping, and extend the offline stub-API test with a built-in-workflow reason comment and a total-count assertion to lock in the scoping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(detection-stats): tighten built-in evidence and gate external default on cutover date Address PR #911 review comments: - Built-in classification (both the workflow-path rollup and the per-run fallback) now requires `conclusion == "success"`. A completed detection job whose steps failed before the `Install threat-detect binary` position (e.g. Setup Scripts failure) is not conclusive built-in evidence and would otherwise cause every sibling run under that workflow path to be excluded from the external reliability rates. - The residual-agentic-→-external fallback is gated on `GHAW_EXTERNAL_DEFAULT_DATE` (default 2026-08-20, matching gh-aw PR #54111's merge). Historical replay of pre-cutover dates keeps unresolved `.lock.yml` runs as `unknown` rather than back-labelling them as external. Fixture gains a `wf-residual` workflow whose detection jobs always fail at Setup Scripts, exercising both properties: pre-cutover they land in `unknown`; with `GHAW_EXTERNAL_DEFAULT_DATE=2026-08-15` (post-cutover) the same runs flip to `external` and their failures count in the error rate and absent verdicts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> --------- Co-authored-by: GitHub Ace <githubnext@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
1 parent de7b42f commit 643efaa

4 files changed

Lines changed: 226 additions & 43 deletions

File tree

.github/workflows/detection-stats-daily.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/detection-stats-daily.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,23 @@ issue: call the `noop` safe-output tool with a short message such as
129129
## What the numbers mean
130130

131131
- **External detector runs** — runs whose `detection` job used the `threat-detect`
132-
binary. This is decided per **workflow**, not per run: a job that was skipped,
133-
cancelled, or died during setup reports few or no steps, so the
134-
`Install threat-detect binary` marker is invisible on exactly the runs that
135-
matter most. If any run of a workflow showed the marker that day, all of that
136-
workflow's detection jobs count as external. Everything under "rates" is
137-
measured over this population. Runs using gh-aw's built-in detection are
138-
counted separately, and runs the evidence cannot settle are reported as
139-
**detector could not be determined** — never folded into either bucket.
132+
binary. Since gh-aw #54111 (merged 2026-08-20) the external detector is the
133+
compile-time default, so this covers every `.lock.yml` run unless the
134+
workflow opts out with `features: gh-aw-detection: false`. Detection is
135+
decided per **workflow**, not per run: a job that was skipped, cancelled, or
136+
died during setup reports few or no steps, so the `Install threat-detect
137+
binary` marker is invisible on exactly the runs that matter most. If any
138+
run of a workflow showed the marker that day, all of that workflow's
139+
detection jobs count as external; if any run had a **successful** detection
140+
job with steps but no marker, all of that workflow's detection jobs count
141+
as built-in. A failed or cancelled job is inconclusive built-in evidence
142+
(it may have died before the install step's position), so it is not enough
143+
to flip a workflow to built-in on its own. On or after the cutover date,
144+
agentic runs with neither signal default to external (matching the new
145+
gh-aw default); on **prior** days, the collector keeps them `unknown`
146+
rather than back-labelling residual runs onto the new default. Only runs
147+
the evidence still cannot settle are reported as **detector could not be
148+
determined** — never folded into either bucket.
140149
- **Job outcomes** — the `detection` job's `conclusion` (`success`, `failure`,
141150
`cancelled`, `skipped`, `timed_out`, `action_required`) or `in_progress` when
142151
the job had not finished at scan time. The **error rate** counts only
@@ -157,7 +166,9 @@ issue: call the `noop` safe-output tool with a short message such as
157166
here; point readers at the `replay-detection` workflow when they want reasons.
158167
- **Reasons reported by gh-aw** — harvested from the `[aw] Detection Runs`
159168
tracking issue, which gh-aw comments on only for `warning`/`failure`
160-
conclusions. `threat_detected` is a **working** detector reporting a finding;
169+
conclusions, and restricted to runs in the external-detector population
170+
above so the counts line up with the rest of the report.
171+
`threat_detected` is a **working** detector reporting a finding;
161172
`agent_failure` and `parse_error` are **tooling failures**.
162173
- **Truncations** — the collector hit a budget or API limit. When present, say so
163174
prominently. Counts are lower bounds, and because the collector works forward

scripts/collect-detection-stats.sh

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ REQUEST_PAUSE_SECONDS="${REQUEST_PAUSE_SECONDS:-0.1}"
4646
readonly WINDOW_RESULT_CAP=1000
4747
readonly PER_PAGE=100
4848
# A job step with this name is emitted only when gh-aw installs the external
49-
# `threat-detect` binary released by this repository.
49+
# `threat-detect` binary released by this repository. Since gh-aw PR #54111
50+
# the `gh-aw-detection` feature defaults to enabled, so the marker is present
51+
# on every compiled workflow that does not explicitly opt out with
52+
# `features: gh-aw-detection: false` in frontmatter.
5053
readonly EXTERNAL_DETECTOR_STEP="Install threat-detect binary"
5154
readonly DETECTION_JOB_NAME="detection"
5255
readonly DETECTION_ARTIFACT_NAME="detection"
@@ -108,6 +111,25 @@ esac
108111
WINDOW_FROM="${TARGET_DATE}T00:00:00Z"
109112
WINDOW_TO="${TARGET_DATE}T23:59:59Z"
110113

114+
# gh-aw PR #54111 (merged 2026-08-20T01:27:02Z) made the external threat
115+
# detector the compile-time default. Runs on days before the cutover cannot
116+
# assume that default: workflows compiled from earlier gh-aw versions used
117+
# built-in detection when no `gh-aw-detection` flag was present. When the
118+
# collector is re-run for a pre-cutover day, keep residual `.lock.yml` runs
119+
# with no direct evidence in the `unknown` bucket rather than mis-labelling
120+
# them external. Overrideable so a future flip can be recorded without a
121+
# code change.
122+
GHAW_EXTERNAL_DEFAULT_DATE="${GHAW_EXTERNAL_DEFAULT_DATE:-2026-08-20}"
123+
case "$GHAW_EXTERNAL_DEFAULT_DATE" in
124+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;;
125+
*) die "GHAW_EXTERNAL_DEFAULT_DATE must be YYYY-MM-DD, got: ${GHAW_EXTERNAL_DEFAULT_DATE}" ;;
126+
esac
127+
if [ "$TARGET_DATE" \< "$GHAW_EXTERNAL_DEFAULT_DATE" ]; then
128+
EXTERNAL_DEFAULT_APPLIES=false
129+
else
130+
EXTERNAL_DEFAULT_APPLIES=true
131+
fi
132+
111133
WORK_DIR="$(mktemp -d)"
112134
trap 'rm -rf "$WORK_DIR"' EXIT
113135
mkdir -p "$OUTPUT_DIR"
@@ -416,13 +438,41 @@ done < <(jq -c '.[]' "${WORK_DIR}/agentic-runs.json")
416438
# The marker step is only observable on a detection job that got far enough to
417439
# reach it. Classifying per run would therefore drop exactly the failures this
418440
# report exists to measure (skipped jobs, cancellations, setup failures) into
419-
# the "built-in detector" bucket and out of every rate. Roll the evidence up to
420-
# the workflow instead: if any run of a workflow shows the marker on the target
421-
# day, that workflow uses the external detector, so all of its detection jobs
422-
# count. Anything still unresolved is reported as `unknown`, never as built-in.
423-
jq -s '
441+
# the wrong bucket. Roll the evidence up to the workflow path instead, both
442+
# ways.
443+
#
444+
# External evidence: any run on the path emitted the `Install threat-detect
445+
# binary` step. One such observation is enough — the step is emitted at
446+
# compile time and the workflow can't switch detectors mid-day.
447+
#
448+
# Built-in evidence: a run on the path had a *successful* `detection` job with
449+
# steps but no marker. `success` matters: a job that failed during
450+
# `Setup Scripts` (before the install step's position) is not evidence that
451+
# the workflow opted out — its own step list is truncated for the same reason
452+
# skipped and cancelled jobs' are, so treating it as built-in evidence would
453+
# mis-classify sibling runs on external workflows that had a bad setup day.
454+
# A completed `success` job, on the other hand, ran to the end of the
455+
# detection recipe; if it never mentioned the marker it really is built-in.
456+
#
457+
# Since gh-aw PR #54111 the external detector is the compile-time default, so
458+
# an agentic `.lock.yml` run with no evidence either way is far more likely
459+
# external than built-in. On or after `$default_date` we default residual
460+
# agentic runs to `external`; before the cutover we leave them `unknown`
461+
# rather than back-date the new default onto historical runs.
462+
jq -s --argjson external_default_applies "$EXTERNAL_DEFAULT_APPLIES" '
424463
. as $records
425464
| ($records | map(select(.detection.marker_seen == true) | .path) | unique) as $external_paths
465+
| ($records
466+
| map(select(
467+
.detection.state == "present"
468+
and .detection.marker_seen == false
469+
and .detection.status == "completed"
470+
and .detection.conclusion == "success"
471+
and (.detection.steps_seen // 0) > 0)
472+
| .path)
473+
| unique) as $builtin_paths
474+
| ($records
475+
| map(select(.path | test("\\.lock\\.ya?ml$"))) | map(.path) | unique) as $agentic_paths
426476
| $records
427477
| map(
428478
if .detection.state != "present" then .
@@ -431,12 +481,23 @@ jq -s '
431481
| .detection.detector =
432482
(if $r.detection.marker_seen then "external"
433483
elif ($external_paths | index($r.path)) then "external"
434-
# A completed job that ran its steps and never mentioned the
435-
# marker really did use gh-aw'"'"'s built-in detection.
484+
elif ($builtin_paths | index($r.path)) then "builtin"
485+
# A completed *and successful* job that ran its steps and never
486+
# mentioned the marker really did use gh-aw'"'"'s built-in
487+
# detection. A failed or cancelled job with a truncated step
488+
# list is inconclusive and falls through to the residual rules
489+
# below.
436490
elif $r.detection.status == "completed"
437-
and $r.detection.conclusion != "skipped"
438-
and $r.detection.conclusion != "cancelled"
491+
and $r.detection.conclusion == "success"
439492
and $r.detection.steps_seen > 0 then "builtin"
493+
# No conclusive evidence either way. External is the compile-
494+
# time default for `.lock.yml` workflows since gh-aw #54111, so
495+
# from that cutover forward residual agentic runs count as
496+
# external. Before the cutover the default was built-in, so
497+
# residual runs stay `unknown` rather than being back-labelled.
498+
# Non-agentic paths shouldn'"'"'t reach this branch (they were
499+
# filtered out earlier), but stay defensive if they do.
500+
elif $external_default_applies and ($agentic_paths | index($r.path)) then "external"
440501
else "unknown" end)
441502
end)
442503
' "$RECORDS_FILE" >"${WORK_DIR}/records.json"
@@ -644,6 +705,8 @@ jq -n \
644705
verdict: ($vmap[(.id | tostring)] // null),
645706
reported: ($rmap[(.id | tostring)] // null)
646707
})) as $ext
708+
| ($ext | map(.id | tostring)) as $ext_ids
709+
| ($reasons | map(select(.run_id as $rid | $ext_ids | index($rid)))) as $ext_reasons
647710
| ($ext | map(select(.detection.status == "completed" and .detection.conclusion == "success"))) as $green
648711
| ($ext | map(select(.verdict != null and .verdict.result == "present"))) as $withverdict
649712
| ($withverdict | map(select(.verdict.prompt_injection or .verdict.secret_leak or .verdict.malicious_patch))) as $threats
@@ -713,7 +776,7 @@ jq -n \
713776
threat_rate_pct: (if ($withverdict | length) == 0 then 0
714777
else ((($threats | length) * 10000 / ($withverdict | length)) | round) / 100 end)
715778
},
716-
reported_reasons: ($reasons | map(.reason // "unknown")
779+
reported_reasons: ($ext_reasons | map(.reason // "unknown")
717780
| group_by(.) | map({key: .[0], value: length}) | from_entries),
718781
by_workflow: ($ext | group_by(.name) | map({
719782
workflow: .[0].name,
@@ -823,10 +886,13 @@ if t["runs_not_inspected"]:
823886
w(f"| Runs never inspected (budget exhausted) | {t['runs_not_inspected']} |")
824887
w("")
825888
w("All rates below are over the **external detector** population "
826-
f"({t['external_detector_runs']} runs). A run is external when its `detection` "
827-
"job showed the `Install threat-detect binary` step, or when another run of "
828-
"the same workflow did that day — a job that was skipped or died during setup "
829-
"reports no steps and would otherwise be miscounted.")
889+
f"({t['external_detector_runs']} runs). Since gh-aw #54111 the external "
890+
"detector is the compile-time default; a run counts as external when its "
891+
"`detection` job showed the `Install threat-detect binary` step, when "
892+
"another run of the same workflow did that day, or when the workflow is "
893+
"`.lock.yml` and no run showed the built-in shape (a completed detection "
894+
"job with steps but no marker). Runs on a workflow that opted out with "
895+
"`features: gh-aw-detection: false` count as built-in.")
830896
if c["rates_cover_partial_day"]:
831897
w("")
832898
w("> **The rates below cover only the inspected part of the day.** "
@@ -894,7 +960,8 @@ w("")
894960
if s["reported_reasons"]:
895961
w("## Reasons reported by gh-aw")
896962
w("")
897-
w("From the `[aw] Detection Runs` tracking issue (warning/failure conclusions only).")
963+
w("From the `[aw] Detection Runs` tracking issue (warning/failure conclusions "
964+
"only), restricted to runs in the external-detector population above.")
898965
w("")
899966
w("| Reason | Count |")
900967
w("|---|---|")

0 commit comments

Comments
 (0)