From a15fc65e2a20cca35396fd1edc5610851bcf5d98 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 07:30:14 +0000
Subject: [PATCH 1/4] Initial plan
From da76d8379e28ddbddbd9007dc88faa775c573db5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 07:41:00 +0000
Subject: [PATCH 2/4] initial plan: fix threat detection engine error banner
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/daily-byok-ollama-test.lock.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/daily-byok-ollama-test.lock.yml b/.github/workflows/daily-byok-ollama-test.lock.yml
index 857f15a3b9a..38ffad98ff2 100644
--- a/.github/workflows/daily-byok-ollama-test.lock.yml
+++ b/.github/workflows/daily-byok-ollama-test.lock.yml
@@ -1451,7 +1451,7 @@ jobs:
printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
(umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}"
- printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"targets\":{\"copilot\":{\"host\":\"host.docker.internal:11434\"}}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
+ printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"defaultAiCreditsPricing\":{\"input\":0.000001,\"output\":0.000001},\"targets\":{\"copilot\":{\"host\":\"host.docker.internal:11434\"}},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
GH_AW_DOCKER_HOST=""
From 94ca20ac26de57e7986259c01d0e8bc0e6933198 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 07:51:18 +0000
Subject: [PATCH 3/4] fix: use distinct XML marker for threat detection engine
errors vs real threats
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
actions/setup/js/generate_footer.test.cjs | 5 +-
actions/setup/js/messages.test.cjs | 2 +
actions/setup/js/messages_run_status.cjs | 4 +-
.../setup/js/push_to_pull_request_branch.cjs | 11 +--
actions/setup/js/threat_detection_warning.cjs | 68 +++++++++++++------
.../js/threat_detection_warning.test.cjs | 19 +++++-
.../js/update_activation_comment.test.cjs | 4 ++
7 files changed, 84 insertions(+), 29 deletions(-)
diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs
index e150ea86133..ebea285cdc9 100644
--- a/actions/setup/js/generate_footer.test.cjs
+++ b/actions/setup/js/generate_footer.test.cjs
@@ -487,7 +487,8 @@ describe("generate_footer.cjs", () => {
expect(result).toContain("> [!WARNING]");
expect(result).toContain("threat detection engine error");
- expect(result).toContain("");
+ expect(result).toContain("");
+ expect(result).not.toContain("");
expect(result).not.toContain("> [!CAUTION]");
expect(result).not.toContain("agentic threat detected");
expect(result).toContain("failed to produce results");
@@ -501,6 +502,8 @@ describe("generate_footer.cjs", () => {
expect(result).toContain("> [!WARNING]");
expect(result).toContain("threat detection engine error");
+ expect(result).toContain("");
+ expect(result).not.toContain("");
expect(result).not.toContain("> [!CAUTION]");
expect(result).not.toContain("agentic threat detected");
expect(result).toContain("could not be parsed");
diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs
index 2f0e8283df7..d19c06eec65 100644
--- a/actions/setup/js/messages.test.cjs
+++ b/actions/setup/js/messages.test.cjs
@@ -1412,6 +1412,8 @@ describe("messages.cjs", () => {
expect(result).toContain("> [!WARNING]");
expect(result).toContain("threat detection engine error");
+ expect(result).toContain("");
+ expect(result).not.toContain("");
expect(result).not.toContain("> [!CAUTION]");
expect(result).not.toContain("agentic threat detected");
expect(result).toContain("threat detection engine failed");
diff --git a/actions/setup/js/messages_run_status.cjs b/actions/setup/js/messages_run_status.cjs
index 445e7cfc925..19fb4e87a45 100644
--- a/actions/setup/js/messages_run_status.cjs
+++ b/actions/setup/js/messages_run_status.cjs
@@ -8,7 +8,7 @@
*/
const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs");
-const { getDetectionReasonText, getThreatDetectedMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs");
+const { getDetectionReasonText, getThreatDetectedMarkerTemplate, getThreatEngineErrorMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs");
/**
* Renders a message using a custom template from config or a default template.
@@ -154,7 +154,7 @@ function getDetectionWarningMessage(ctx) {
const reasonText = getDetectionReasonText(ctx.reason);
const isEngineError = isToolingFailureReason(ctx.reason);
if (isEngineError) {
- const defaultTemplate = `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatDetectedMarkerTemplate()}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
+ const defaultTemplate = `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatEngineErrorMarkerTemplate()}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
return renderConfiguredMessage("detectionEngineError", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) });
}
const defaultTemplate = `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarkerTemplate()}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index e29228d2487..aa550fd2070 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -21,7 +21,7 @@ const { withGitHubHostToken } = require("./git_auth_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs");
const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
-const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs");
+const { getThreatDetectedMarker, isToolingFailureReason } = require("./threat_detection_warning.cjs");
const { attachExecutionState } = require("./safe_output_execution_metadata.cjs");
const { resolveTransportPaths } = require("./resolve_transport_paths.cjs");
@@ -1304,10 +1304,13 @@ async function main(config = {}) {
// For fork-backed PRs, use an owner-qualified head reference.
const reviewHeadRef = pushRemoteUrl ? `${pushRepoParts.owner}:${reviewBranchName}` : reviewBranchName;
const detectionReasonEnv = process.env.GH_AW_DETECTION_REASON || "unknown";
+ const isEngineError = isToolingFailureReason(detectionReasonEnv);
const prBody = [
- "> [!CAUTION]",
- "> agentic threat detected",
- "> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.",
+ isEngineError ? "> [!WARNING]" : "> [!CAUTION]",
+ isEngineError ? "> threat detection engine error" : "> agentic threat detected",
+ isEngineError
+ ? "> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding."
+ : "> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.",
`> ${getThreatDetectedMarker(detectionReasonEnv)}`,
">",
`> **Reason:** ${detectionReasonEnv}`,
diff --git a/actions/setup/js/threat_detection_warning.cjs b/actions/setup/js/threat_detection_warning.cjs
index 4202bffae5f..3df81dfac63 100644
--- a/actions/setup/js/threat_detection_warning.cjs
+++ b/actions/setup/js/threat_detection_warning.cjs
@@ -20,25 +20,6 @@ function normalizeThreatKinds(reason) {
return kinds.length > 0 ? Array.from(new Set(kinds)).join(",") : "unknown";
}
-/**
- * Returns the XML marker used to identify threat-detected output.
- *
- * @param {string | undefined | null} reason
- * @returns {string}
- */
-function getThreatDetectedMarker(reason) {
- return "";
-}
-
-/**
- * Returns the marker template for configured message rendering.
- *
- * @returns {string}
- */
-function getThreatDetectedMarkerTemplate() {
- return "";
-}
-
/**
* Returns a human-readable reason text for detection warnings.
*
@@ -69,10 +50,59 @@ function isToolingFailureReason(reason) {
return normalized === "agent_failure" || normalized === "parse_error";
}
+/**
+ * Returns the XML marker used to identify threat-engine-error output.
+ * This marker is distinct from the real-threat marker so that automated tools
+ * can distinguish a tooling failure from an actual security finding.
+ *
+ * @returns {string}
+ */
+function getThreatEngineErrorMarker() {
+ return "";
+}
+
+/**
+ * Returns the marker template for configured engine-error message rendering.
+ *
+ * @returns {string}
+ */
+function getThreatEngineErrorMarkerTemplate() {
+ return "";
+}
+
+/**
+ * Returns the XML marker used to identify threat-detected output.
+ * When the reason indicates a tooling failure (agent_failure, parse_error) a
+ * distinct engine-error marker is returned so automated tools can distinguish
+ * "detection engine crashed" from "detection engine found something".
+ *
+ * @param {string | undefined | null} reason
+ * @returns {string}
+ */
+function getThreatDetectedMarker(reason) {
+ if (isToolingFailureReason(reason)) {
+ return getThreatEngineErrorMarker();
+ }
+ return "";
+}
+
+/**
+ * Returns the marker template for configured message rendering.
+ * Always returns the real-threat marker; use getThreatEngineErrorMarkerTemplate()
+ * for tooling-failure templates where the reason is known at template-build time.
+ *
+ * @returns {string}
+ */
+function getThreatDetectedMarkerTemplate() {
+ return "";
+}
+
module.exports = {
normalizeThreatKinds,
getThreatDetectedMarker,
getThreatDetectedMarkerTemplate,
+ getThreatEngineErrorMarker,
+ getThreatEngineErrorMarkerTemplate,
getDetectionReasonText,
isToolingFailureReason,
};
diff --git a/actions/setup/js/threat_detection_warning.test.cjs b/actions/setup/js/threat_detection_warning.test.cjs
index f9366c5afbb..ec84af11200 100644
--- a/actions/setup/js/threat_detection_warning.test.cjs
+++ b/actions/setup/js/threat_detection_warning.test.cjs
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { normalizeThreatKinds, getThreatDetectedMarker, getThreatDetectedMarkerTemplate, getDetectionReasonText, isToolingFailureReason } from "./threat_detection_warning.cjs";
+import { normalizeThreatKinds, getThreatDetectedMarker, getThreatDetectedMarkerTemplate, getThreatEngineErrorMarker, getThreatEngineErrorMarkerTemplate, getDetectionReasonText, isToolingFailureReason } from "./threat_detection_warning.cjs";
describe("threat_detection_warning", () => {
describe("normalizeThreatKinds", () => {
@@ -15,10 +15,23 @@ describe("threat_detection_warning", () => {
});
describe("marker helpers", () => {
- it("emits the normative threat marker", () => {
- expect(getThreatDetectedMarker("threat_detected,parse_error")).toBe("");
+ it("emits the normative threat marker for real threats", () => {
+ expect(getThreatDetectedMarker("threat_detected")).toBe("");
+ expect(getThreatDetectedMarker(null)).toBe("");
+ expect(getThreatDetectedMarker(undefined)).toBe("");
+ expect(getThreatDetectedMarker("")).toBe("");
expect(getThreatDetectedMarkerTemplate()).toBe("");
});
+
+ it("emits the engine-error marker for tooling failures", () => {
+ expect(getThreatDetectedMarker("agent_failure")).toBe("");
+ expect(getThreatDetectedMarker("parse_error")).toBe("");
+ });
+
+ it("getThreatEngineErrorMarker always returns the engine-error marker", () => {
+ expect(getThreatEngineErrorMarker()).toBe("");
+ expect(getThreatEngineErrorMarkerTemplate()).toBe("");
+ });
});
describe("getDetectionReasonText", () => {
diff --git a/actions/setup/js/update_activation_comment.test.cjs b/actions/setup/js/update_activation_comment.test.cjs
index faa5a85f368..65000d9b384 100644
--- a/actions/setup/js/update_activation_comment.test.cjs
+++ b/actions/setup/js/update_activation_comment.test.cjs
@@ -84,6 +84,10 @@ const createTestableFunction = scriptContent => {
parse_error: "The threat detection results could not be parsed.",
};
const reasonText = reasons[reason] || "The threat detection analysis could not be completed.";
+ const isEngineError = reason === "agent_failure" || reason === "parse_error";
+ if (isEngineError) {
+ return `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> \n>\n> ${reasonText}`;
+ }
return `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> \n>\n> ${reasonText}`;
},
};
From dbb215a98fd7d1fd0dfed97987bf0a3c44ac6184 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 08:33:01 +0000
Subject: [PATCH 4/4] fix threat warning review coverage
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
---
actions/setup/js/messages_run_status.cjs | 7 +-
.../setup/js/push_to_pull_request_branch.cjs | 14 +-
.../js/push_to_pull_request_branch.test.cjs | 126 ++++++++++--------
actions/setup/js/threat_detection_warning.cjs | 26 ++++
.../js/threat_detection_warning.test.cjs | 26 +++-
.../js/update_activation_comment.test.cjs | 59 +++++---
.../docs/specs/safe-outputs-specification.md | 18 ++-
7 files changed, 193 insertions(+), 83 deletions(-)
diff --git a/actions/setup/js/messages_run_status.cjs b/actions/setup/js/messages_run_status.cjs
index 19fb4e87a45..6796c7a60ee 100644
--- a/actions/setup/js/messages_run_status.cjs
+++ b/actions/setup/js/messages_run_status.cjs
@@ -8,7 +8,7 @@
*/
const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs");
-const { getDetectionReasonText, getThreatDetectedMarkerTemplate, getThreatEngineErrorMarkerTemplate, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs");
+const { getDetectionReasonText, getThreatWarningPresentation, normalizeThreatKinds, isToolingFailureReason } = require("./threat_detection_warning.cjs");
/**
* Renders a message using a custom template from config or a default template.
@@ -152,12 +152,13 @@ function getCommitPushedMessage(ctx) {
*/
function getDetectionWarningMessage(ctx) {
const reasonText = getDetectionReasonText(ctx.reason);
+ const presentation = getThreatWarningPresentation(ctx.reason);
const isEngineError = isToolingFailureReason(ctx.reason);
if (isEngineError) {
- const defaultTemplate = `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> ${getThreatEngineErrorMarkerTemplate()}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
+ const defaultTemplate = `> [!${presentation.admonition}]\n> ${presentation.title}\n> ${presentation.summary}\n> ${presentation.marker}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
return renderConfiguredMessage("detectionEngineError", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) });
}
- const defaultTemplate = `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> ${getThreatDetectedMarkerTemplate()}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
+ const defaultTemplate = `> [!${presentation.admonition}]\n> ${presentation.title}\n> ${presentation.summary}\n> ${presentation.marker}\n>\n> \n> Details
\n>\n> {reason_text}\n>\n> Review the [workflow run logs]({run_url}) for details.\n> `;
return renderConfiguredMessage("detectionWarning", defaultTemplate, { ...ctx, reasonText, threatKinds: normalizeThreatKinds(ctx.reason) });
}
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index aa550fd2070..28410c54fb1 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -21,7 +21,7 @@ const { withGitHubHostToken } = require("./git_auth_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs");
const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
-const { getThreatDetectedMarker, isToolingFailureReason } = require("./threat_detection_warning.cjs");
+const { getThreatWarningPresentation } = require("./threat_detection_warning.cjs");
const { attachExecutionState } = require("./safe_output_execution_metadata.cjs");
const { resolveTransportPaths } = require("./resolve_transport_paths.cjs");
@@ -1304,14 +1304,12 @@ async function main(config = {}) {
// For fork-backed PRs, use an owner-qualified head reference.
const reviewHeadRef = pushRemoteUrl ? `${pushRepoParts.owner}:${reviewBranchName}` : reviewBranchName;
const detectionReasonEnv = process.env.GH_AW_DETECTION_REASON || "unknown";
- const isEngineError = isToolingFailureReason(detectionReasonEnv);
+ const warning = getThreatWarningPresentation(detectionReasonEnv);
const prBody = [
- isEngineError ? "> [!WARNING]" : "> [!CAUTION]",
- isEngineError ? "> threat detection engine error" : "> agentic threat detected",
- isEngineError
- ? "> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding."
- : "> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.",
- `> ${getThreatDetectedMarker(detectionReasonEnv)}`,
+ `> [!${warning.admonition}]`,
+ `> ${warning.title}`,
+ `> ${warning.summary}`,
+ `> ${warning.marker}`,
">",
`> **Reason:** ${detectionReasonEnv}`,
">",
diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs
index 1f3178f2569..ab9849b8e7c 100644
--- a/actions/setup/js/push_to_pull_request_branch.test.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.test.cjs
@@ -253,6 +253,57 @@ describe("push_to_pull_request_branch.cjs", () => {
return module;
}
+ async function runFallbackPullRequestScenario(branch, detectionReason = "unknown") {
+ createPatchFile(branch);
+ process.env.GH_AW_DETECTION_REASON = detectionReason;
+
+ mockExec.exec.mockResolvedValueOnce(0); // fetch
+ mockExec.exec.mockResolvedValueOnce(0); // rev-parse
+ mockExec.exec.mockResolvedValueOnce(0); // checkout
+
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)
+
+ mockExec.exec.mockResolvedValueOnce(0); // git am
+
+ const originalGetExecOutput = mockExec.getExecOutput;
+ mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args) => {
+ const argList = Array.isArray(args) ? args : [];
+ if (argList[0] === "rev-parse" && argList[1] === "origin/feature-branch^{commit}") {
+ return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\n", stderr: "" };
+ }
+ if (argList[0] === "rev-list" && argList[1] === "--merges") {
+ return { exitCode: 0, stdout: "0\n", stderr: "" };
+ }
+ if (argList[0] === "rev-list" && argList[1] === "--parents") {
+ return {
+ exitCode: 0,
+ stdout: "2222222222222222222222222222222222222222 1111111111111111111111111111111111111111\n",
+ stderr: "",
+ };
+ }
+ if (argList[0] === "ls-remote" && argList[2] === "refs/heads/feature-branch") {
+ return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\trefs/heads/feature-branch\n", stderr: "" };
+ }
+ if (argList[0] === "log") {
+ if (argList.includes(".github/workflows/")) {
+ return { exitCode: 0, stdout: "", stderr: "" };
+ }
+ return { exitCode: 0, stdout: "Test commit\n", stderr: "" };
+ }
+ if (argList[0] === "diff-tree") {
+ return { exitCode: 0, stdout: "", stderr: "" };
+ }
+ return originalGetExecOutput(cmd, args);
+ });
+
+ mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
+ mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));
+
+ const module = await loadModule();
+ const handler = await module.main({});
+ return handler({ branch }, {});
+ }
+
/**
* Helper to create a valid patch file at the canonical path derived from the
* message branch. The privileged handler always re-derives the patch path
@@ -1171,59 +1222,7 @@ index 0000000..abc1234
});
it("should create fallback pull request on non-fast-forward push rejection by default", async () => {
- const patchPath = createPatchFile("should-create-fallback-pull-request-on-non-fast-forward-push");
-
- // Set up successful operations until push
- mockExec.exec.mockResolvedValueOnce(0); // fetch
- mockExec.exec.mockResolvedValueOnce(0); // rev-parse
- mockExec.exec.mockResolvedValueOnce(0); // checkout
-
- mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)
-
- mockExec.exec.mockResolvedValueOnce(0); // git am
-
- const originalGetExecOutput = mockExec.getExecOutput;
- mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args) => {
- const argList = Array.isArray(args) ? args : [];
- if (argList[0] === "rev-parse" && argList[1] === "origin/feature-branch^{commit}") {
- return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\n", stderr: "" };
- }
- if (argList[0] === "rev-list" && argList[1] === "--merges") {
- return { exitCode: 0, stdout: "0\n", stderr: "" };
- }
- if (argList[0] === "rev-list" && argList[1] === "--parents") {
- return {
- exitCode: 0,
- stdout: "2222222222222222222222222222222222222222 1111111111111111111111111111111111111111\n",
- stderr: "",
- };
- }
- if (argList[0] === "ls-remote" && argList[2] === "refs/heads/feature-branch") {
- return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\trefs/heads/feature-branch\n", stderr: "" };
- }
- if (argList[0] === "log") {
- // Pre-flight workflow check targets .github/workflows/; return empty to avoid
- // short-circuiting the fallback path with a workflows_scope_required error.
- if (argList.includes(".github/workflows/")) {
- return { exitCode: 0, stdout: "", stderr: "" };
- }
- return { exitCode: 0, stdout: "Test commit\n", stderr: "" };
- }
- if (argList[0] === "diff-tree") {
- return { exitCode: 0, stdout: "", stderr: "" };
- }
- return originalGetExecOutput(cmd, args);
- });
-
- // GraphQL call fails, triggering fallback to git push
- mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
-
- // Fallback git push also fails with non-fast-forward
- mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));
-
- const module = await loadModule();
- const handler = await module.main({});
- const result = await handler({ branch: "should-create-fallback-pull-request-on-non-fast-forward-push" }, {});
+ const result = await runFallbackPullRequestScenario("should-create-fallback-pull-request-on-non-fast-forward-push");
expect(result.success).toBe(true);
expect(result.fallback_used).toBe(true);
@@ -1466,6 +1465,27 @@ index 0000000..abc1234
mockExec.getExecOutput = savedGetExecOutput;
});
+ it.each([
+ ["agent_failure", "> [!WARNING]", "> threat detection engine error", "", ""],
+ ["threat_detected", "> [!CAUTION]", "> agentic threat detected", "", ""],
+ ])("should create a review PR body for %s with the correct admonition and marker", async (reason, admonition, title, expectedMarker, unexpectedMarker) => {
+ process.env.GH_AW_DETECTION_CONCLUSION = "warning";
+ process.env.GH_AW_DETECTION_REASON = reason;
+ mockContext.runId = 12345;
+ createPatchFile(`review-branch-body-${reason}`);
+
+ const module = await loadModule();
+ const handler = await module.main({});
+ const result = await handler({ branch: `review-branch-body-${reason}` }, {});
+
+ expect(result.success).toBe(true);
+ const [params] = mockGithub.rest.pulls.create.mock.calls.at(-1);
+ expect(params.body).toContain(admonition);
+ expect(params.body).toContain(title);
+ expect(params.body).toContain(expectedMarker);
+ expect(params.body).not.toContain(unexpectedMarker);
+ });
+
it("should skip non-fatally when review branch is rejected for workflows scope (timeout variant, agent has none)", async () => {
process.env.GH_AW_DETECTION_CONCLUSION = "warning";
createPatchFile("review-branch-workflows-scope-timeout");
diff --git a/actions/setup/js/threat_detection_warning.cjs b/actions/setup/js/threat_detection_warning.cjs
index 3df81dfac63..ba8dadd783d 100644
--- a/actions/setup/js/threat_detection_warning.cjs
+++ b/actions/setup/js/threat_detection_warning.cjs
@@ -70,6 +70,31 @@ function getThreatEngineErrorMarkerTemplate() {
return "";
}
+/**
+ * Returns the review-warning presentation associated with a detection reason.
+ * Centralizing these fields keeps admonition copy and marker routing in sync
+ * across status messages, footers, and fallback pull request bodies.
+ *
+ * @param {string | undefined | null} reason
+ * @returns {{admonition: string, title: string, summary: string, marker: string}}
+ */
+function getThreatWarningPresentation(reason) {
+ if (isToolingFailureReason(reason)) {
+ return {
+ admonition: "WARNING",
+ title: "threat detection engine error",
+ summary: "The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.",
+ marker: getThreatEngineErrorMarker(),
+ };
+ }
+ return {
+ admonition: "CAUTION",
+ title: "agentic threat detected",
+ summary: "Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.",
+ marker: getThreatDetectedMarker(reason),
+ };
+}
+
/**
* Returns the XML marker used to identify threat-detected output.
* When the reason indicates a tooling failure (agent_failure, parse_error) a
@@ -99,6 +124,7 @@ function getThreatDetectedMarkerTemplate() {
module.exports = {
normalizeThreatKinds,
+ getThreatWarningPresentation,
getThreatDetectedMarker,
getThreatDetectedMarkerTemplate,
getThreatEngineErrorMarker,
diff --git a/actions/setup/js/threat_detection_warning.test.cjs b/actions/setup/js/threat_detection_warning.test.cjs
index ec84af11200..18c6b68396e 100644
--- a/actions/setup/js/threat_detection_warning.test.cjs
+++ b/actions/setup/js/threat_detection_warning.test.cjs
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
-import { normalizeThreatKinds, getThreatDetectedMarker, getThreatDetectedMarkerTemplate, getThreatEngineErrorMarker, getThreatEngineErrorMarkerTemplate, getDetectionReasonText, isToolingFailureReason } from "./threat_detection_warning.cjs";
+import {
+ normalizeThreatKinds,
+ getThreatWarningPresentation,
+ getThreatDetectedMarker,
+ getThreatDetectedMarkerTemplate,
+ getThreatEngineErrorMarker,
+ getThreatEngineErrorMarkerTemplate,
+ getDetectionReasonText,
+ isToolingFailureReason,
+} from "./threat_detection_warning.cjs";
describe("threat_detection_warning", () => {
describe("normalizeThreatKinds", () => {
@@ -32,6 +41,21 @@ describe("threat_detection_warning", () => {
expect(getThreatEngineErrorMarker()).toBe("");
expect(getThreatEngineErrorMarkerTemplate()).toBe("");
});
+
+ it("returns a centralized warning presentation for tooling failures and threats", () => {
+ expect(getThreatWarningPresentation("agent_failure")).toEqual({
+ admonition: "WARNING",
+ title: "threat detection engine error",
+ summary: "The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.",
+ marker: "",
+ });
+ expect(getThreatWarningPresentation("threat_detected")).toEqual({
+ admonition: "CAUTION",
+ title: "agentic threat detected",
+ summary: "Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.",
+ marker: "",
+ });
+ });
});
describe("getDetectionReasonText", () => {
diff --git a/actions/setup/js/update_activation_comment.test.cjs b/actions/setup/js/update_activation_comment.test.cjs
index 65000d9b384..1a2962b943b 100644
--- a/actions/setup/js/update_activation_comment.test.cjs
+++ b/actions/setup/js/update_activation_comment.test.cjs
@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readFileSync } from "fs";
import path from "path";
+import * as threatDetectionWarning from "./threat_detection_warning.cjs";
const createTestableFunction = scriptContent => {
const beforeMainCall = scriptContent.match(/^([\s\S]*?)\s*module\.exports\s*=\s*{[\s\S]*?};?\s*$/);
if (!beforeMainCall) throw new Error("Could not extract script content before module.exports");
let scriptBody = beforeMainCall[1];
// Mock the error_helpers and messages_core modules
- const mockRequire = module => {
+ const mockRequire = (module, threatDetectionWarning) => {
if (module === "./error_helpers.cjs") {
return { getErrorMessage: error => (error instanceof Error ? error.message : String(error)) };
}
@@ -56,6 +57,11 @@ const createTestableFunction = scriptContent => {
const def = "Commit pushed: [`{short_sha}`]({commit_url})";
return messages?.commitPushed ? renderTmpl(messages.commitPushed, tc) : renderTmpl(def, tc);
},
+ getDetectionWarningMessage: ctx => {
+ const reasonText = threatDetectionWarning.getDetectionReasonText(ctx.reason);
+ const warning = threatDetectionWarning.getThreatWarningPresentation(ctx.reason);
+ return `> [!${warning.admonition}]\n> ${warning.title}\n> ${warning.summary}\n> ${warning.marker}\n>\n> ${reasonText}`;
+ },
};
}
if (module === "./templatable.cjs") {
@@ -74,24 +80,17 @@ const createTestableFunction = scriptContent => {
if (module === "./messages_footer.cjs") {
return {
getFooterMessage: ctx => `> Generated by [${ctx.workflowName}](${ctx.runUrl})`,
- getDetectionCautionAlert: () => {
+ getDetectionCautionAlert: (workflowName, runUrl) => {
const conclusion = process.env.GH_AW_DETECTION_CONCLUSION;
if (conclusion !== "warning") return "";
const reason = process.env.GH_AW_DETECTION_REASON || "";
- const reasons = {
- threat_detected: "Potential security threats were detected in the agent output.",
- agent_failure: "The threat detection engine failed to produce results.",
- parse_error: "The threat detection results could not be parsed.",
- };
- const reasonText = reasons[reason] || "The threat detection analysis could not be completed.";
- const isEngineError = reason === "agent_failure" || reason === "parse_error";
- if (isEngineError) {
- return `> [!WARNING]\n> threat detection engine error\n> The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.\n> \n>\n> ${reasonText}`;
- }
- return `> [!CAUTION]\n> agentic threat detected\n> Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.\n> \n>\n> ${reasonText}`;
+ return mockRequire("./messages_run_status.cjs", threatDetectionWarning).getDetectionWarningMessage({ workflowName, runUrl, reason });
},
};
}
+ if (module === "./threat_detection_warning.cjs") {
+ return threatDetectionWarning;
+ }
if (module === "./workflow_metadata_helpers.cjs") {
return {
buildWorkflowRunUrl: (ctx, repo) => {
@@ -162,7 +161,7 @@ const createTestableFunction = scriptContent => {
throw new Error(`Module ${module} not mocked in test`);
};
return new Function(
- `\n const { github, core, context, process } = arguments[0];\n const require = ${mockRequire.toString()};\n \n ${scriptBody}\n \n return { updateActivationComment, updateActivationCommentWithCommit, updateActivationCommentWithMessage };\n `
+ `\n const { github, core, context, process, threatDetectionWarning } = arguments[0];\n const mockRequire = ${mockRequire.toString()};\n const require = module => mockRequire(module, threatDetectionWarning);\n \n ${scriptBody}\n \n return { updateActivationComment, updateActivationCommentWithCommit, updateActivationCommentWithMessage };\n `
);
};
describe("update_activation_comment.cjs", () => {
@@ -171,7 +170,13 @@ describe("update_activation_comment.cjs", () => {
const scriptPath = path.join(process.cwd(), "update_activation_comment.cjs"),
scriptContent = readFileSync(scriptPath, "utf8");
((createFunctionFromScript = createTestableFunction(scriptContent)),
- (mockDependencies = { github: { graphql: vi.fn(), request: vi.fn() }, core: { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }, context: { repo: { owner: "testowner", repo: "testrepo" } }, process: { env: {} } }));
+ (mockDependencies = {
+ github: { graphql: vi.fn(), request: vi.fn() },
+ core: { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() },
+ context: { repo: { owner: "testowner", repo: "testrepo" } },
+ process: { env: {} },
+ threatDetectionWarning,
+ }));
}),
it("should skip comment when no activation comment ID or triggering context exists", async () => {
mockDependencies.process.env.GH_AW_COMMENT_ID = "";
@@ -223,6 +228,30 @@ describe("update_activation_comment.cjs", () => {
})
);
}),
+ it.each([
+ ["agent_failure", "> threat detection engine error", "", ""],
+ ["threat_detected", "> agentic threat detected", "", ""],
+ ])("should include the %s detection banner in created activation comments", async (reason, titleLine, expectedMarker, unexpectedMarker) => {
+ mockDependencies.process.env.GH_AW_COMMENT_ID = "";
+ mockDependencies.process.env.GH_AW_DETECTION_CONCLUSION = "warning";
+ mockDependencies.process.env.GH_AW_DETECTION_REASON = reason;
+ mockDependencies.context = {
+ ...mockDependencies.context,
+ runId: 12345,
+ payload: { pull_request: { number: 10 } },
+ };
+ mockDependencies.github.request.mockResolvedValue({
+ data: { id: 789012, html_url: "https://github.com/testowner/testrepo/issues/10#issuecomment-789012" },
+ });
+
+ const { updateActivationComment } = createFunctionFromScript(mockDependencies);
+ await updateActivationComment(mockDependencies.github, mockDependencies.context, mockDependencies.core, "https://github.com/testowner/testrepo/pull/42", 42);
+
+ const [, requestParams] = mockDependencies.github.request.mock.calls[0];
+ expect(requestParams.body).toContain(titleLine);
+ expect(requestParams.body).toContain(expectedMarker);
+ expect(requestParams.body).not.toContain(unexpectedMarker);
+ }),
it("should skip update when GH_AW_COMMENT_ID is not set and no target issue number", async () => {
mockDependencies.process.env.GH_AW_COMMENT_ID = "";
const { updateActivationCommentWithMessage } = createFunctionFromScript(mockDependencies);
diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md
index 47891e78e92..e88b2f45b02 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -4577,7 +4577,7 @@ Operations execute in:
When threat detection executes in `warn` mode and reports a threat signal for a safe output, implementations MUST apply a type-specific fallback policy before any safe output side effect is committed.
-**Requirement WTD1 (Reviewable Annotation)**: For safe output types classified as **Reviewable** in Table WTD-A, implementations MUST convert the output into a review-first artifact that includes all of the following:
+**Requirement WTD1 (Reviewable Annotation)**: For safe output types classified as **Reviewable** in Table WTD-A, implementations MUST convert the output into a review-first artifact that includes all of the following when threat detection reports an actual threat verdict (`threat_detected`):
1. A prominent caution section:
@@ -4588,7 +4588,18 @@ When threat detection executes in `warn` mode and reports a threat signal for a
2. A visible threat label string: `agentic threat detected`.
3. An XML comment marker in emitted markdown content: ``.
-**Requirement WTD2 (Convertible Fallback)**: For safe output types classified as **Convertible**, implementations MUST transform the operation into the mapped Reviewable type before execution. For this specification, `push_to_pull_request_branch` (also referred to as `update-pull-request-branch`) MUST fall back to `create_pull_request` with the WTD1 caution, label, and XML marker.
+**Requirement WTD1a (Threat Engine Error Annotation)**: When warn-mode threat detection produces a tooling-failure reason (`agent_failure` or `parse_error`) instead of a real threat verdict, implementations MUST emit a review-first artifact with all of the following:
+
+1. A prominent warning section:
+
+ > [!WARNING]
+ > threat detection engine error
+ > The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.
+
+2. A visible warning label string: `threat detection engine error`.
+3. An XML comment marker in emitted markdown content: ``.
+
+**Requirement WTD2 (Convertible Fallback)**: For safe output types classified as **Convertible**, implementations MUST transform the operation into the mapped Reviewable type before execution. For this specification, `push_to_pull_request_branch` (also referred to as `update-pull-request-branch`) MUST fall back to `create_pull_request` with the WTD1 or WTD1a review annotation that matches the detection reason.
**Requirement WTD3 (Non-Reviewable Abort)**: For safe output types classified as **Abort**, implementations MUST NOT apply the original safe output. Implementations MUST activate a threat-detected code path, emit an explicit failure summary, and return a machine-readable threat-detected error outcome.
@@ -4639,7 +4650,7 @@ When threat detection executes in `warn` mode and reports a threat signal for a
**Compliance Testing**:
-- **T-WTD-001**: Reviewable outputs include CAUTION block, label text `agentic threat detected`, and XML comment marker.
+- **T-WTD-001**: Reviewable outputs include the threat-appropriate annotation: WTD1 CAUTION block/label/marker for `threat_detected`, or WTD1a WARNING block/label/marker for `agent_failure` and `parse_error`.
- **T-WTD-002**: `push_to_pull_request_branch` in warn-mode threat failure is converted to `create_pull_request`.
- **T-WTD-003**: Abort-class outputs are not applied and produce threat-detected error outcomes.
@@ -5448,6 +5459,7 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries
- **Added**: `add_comment` status-comment reuse extension semantics in Section 7.1 for `target: "status"` behavior and issue/PR-only restrictions.
- **Added**: Changelog alignment subsection mapping safe-output/reviewer changelog items to this specification revision.
- **Updated**: Publication metadata to 1.21.0.
+- **Clarified**: Section 10.5 now distinguishes real threat verdict annotations (``) from threat-engine tooling failures (``).
**Version 1.20.0** (2026-05-15):