diff --git a/actions/setup/js/setup_threat_detection.cjs b/actions/setup/js/setup_threat_detection.cjs index febd94eb7b2..03401154ab9 100644 --- a/actions/setup/js/setup_threat_detection.cjs +++ b/actions/setup/js/setup_threat_detection.cjs @@ -31,15 +31,33 @@ async function main() { return; } const templateContent = fs.readFileSync(templatePath, "utf-8"); - // Check if prompt file exists + // Check if prompt file exists (soft check; detection can continue with fallback context) // The agent artifact is downloaded to /tmp/gh-aw/threat-detection/ // GitHub Actions preserves the directory structure from the uploaded artifact // (stripping the common /tmp/gh-aw/ prefix from the uploaded paths) // So /tmp/gh-aw/aw-prompts/prompt.txt becomes /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt const threatDetectionDir = "/tmp/gh-aw/threat-detection"; const promptPath = path.join(threatDetectionDir, "aw-prompts/prompt.txt"); - if (!checkFileExists(promptPath, threatDetectionDir, "Prompt file", true)) { - return; + let promptFileInfo; + if (!fs.existsSync(promptPath)) { + promptFileInfo = `${promptPath} (unavailable)`; + core.warning( + `⚠️ ${ERR_VALIDATION}: Missing workflow prompt context at ${promptPath}. ` + + "Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. " + + "Threat detection will continue with fallback workflow context." + ); + } else { + const promptStats = fs.statSync(promptPath); + if (promptStats.size === 0) { + promptFileInfo = `${promptPath} (unavailable)`; + core.warning( + `⚠️ ${ERR_VALIDATION}: Workflow prompt context is empty at ${promptPath}. ` + + "Threat detection will continue with fallback workflow context." + ); + } else { + core.info(`Prompt file found: ${promptPath} (${promptStats.size} bytes)`); + promptFileInfo = `${promptPath} (${promptStats.size} bytes)`; + } } // Check if agent output file exists @@ -73,7 +91,6 @@ async function main() { } // Get file info for template replacement - const promptFileInfo = promptPath + " (" + fs.statSync(promptPath).size + " bytes)"; const agentOutputFileInfo = agentOutputPath + " (" + fs.statSync(agentOutputPath).size + " bytes)"; const commentMemoryDir = path.join(threatDetectionDir, "comment-memory"); let commentMemoryFileInfo = "No comment-memory files found"; diff --git a/actions/setup/js/setup_threat_detection.test.cjs b/actions/setup/js/setup_threat_detection.test.cjs new file mode 100644 index 00000000000..8473d365066 --- /dev/null +++ b/actions/setup/js/setup_threat_detection.test.cjs @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; + +const TMP_ROOT = "/tmp/gh-aw"; +const THREAT_DIR = path.join(TMP_ROOT, "threat-detection"); +const TEMPLATE_DIR = "/tmp/gh-aw-test-prompts"; + +describe("setup_threat_detection", () => { + beforeEach(() => { + vi.resetModules(); + fs.rmSync(TMP_ROOT, { recursive: true, force: true }); + fs.rmSync(TEMPLATE_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEMPLATE_DIR, { recursive: true }); + + fs.writeFileSync( + path.join(TEMPLATE_DIR, "threat_detection.md"), + "name={WORKFLOW_NAME}\ndescription={WORKFLOW_DESCRIPTION}\nprompt={WORKFLOW_PROMPT_FILE}\noutput={AGENT_OUTPUT_FILE}\ncomment={COMMENT_MEMORY_FILES}\npatch={AGENT_PATCH_FILE}\n" + ); + + fs.mkdirSync(THREAT_DIR, { recursive: true }); + fs.writeFileSync(path.join(THREAT_DIR, "agent_output.json"), '{"ok":true}'); + + process.env.GH_AW_PROMPTS_DIR = TEMPLATE_DIR; + process.env.WORKFLOW_NAME = "Test Workflow"; + process.env.WORKFLOW_DESCRIPTION = "Test Description"; + }); + + afterEach(() => { + fs.rmSync(TMP_ROOT, { recursive: true, force: true }); + fs.rmSync(TEMPLATE_DIR, { recursive: true, force: true }); + delete process.env.GH_AW_PROMPTS_DIR; + delete process.env.WORKFLOW_NAME; + delete process.env.WORKFLOW_DESCRIPTION; + }); + + function setupCoreMocks() { + const summary = { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }; + global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + exportVariable: vi.fn(), + summary, + }; + } + + it("continues with fallback workflow context when prompt artifact is missing", async () => { + setupCoreMocks(); + + const module = await import("./setup_threat_detection.cjs"); + await module.main(); + + expect(global.core.setFailed).not.toHaveBeenCalled(); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Missing workflow prompt context")); + expect(global.core.exportVariable).toHaveBeenCalledWith("GH_AW_PROMPT", "/tmp/gh-aw/aw-prompts/prompt.txt"); + + const generatedPromptPath = "/tmp/gh-aw/aw-prompts/prompt.txt"; + expect(fs.existsSync(generatedPromptPath)).toBe(true); + const generatedPrompt = fs.readFileSync(generatedPromptPath, "utf8"); + expect(generatedPrompt).toContain("name=Test Workflow"); + expect(generatedPrompt).toContain("description=Test Description"); + expect(generatedPrompt).toContain("prompt=/tmp/gh-aw/threat-detection/aw-prompts/prompt.txt (unavailable)"); + }); + + it("warns but continues when prompt artifact is empty", async () => { + setupCoreMocks(); + const promptDir = path.join(THREAT_DIR, "aw-prompts"); + fs.mkdirSync(promptDir, { recursive: true }); + fs.writeFileSync(path.join(promptDir, "prompt.txt"), ""); + + const module = await import("./setup_threat_detection.cjs"); + await module.main(); + + expect(global.core.setFailed).not.toHaveBeenCalled(); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("is empty")); + expect(global.core.exportVariable).toHaveBeenCalledWith("GH_AW_PROMPT", "/tmp/gh-aw/aw-prompts/prompt.txt"); + }); +}); diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 6f1546d7081..bf93583a520 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -423,6 +423,9 @@ func (c *Compiler) buildPrepareDetectionFilesStep() []string { " run: |\n", " mkdir -p /tmp/gh-aw/threat-detection/aw-prompts\n", " cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true\n", + " if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then\n", + " echo \"::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context.\"\n", + " fi\n", " cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true\n", " for f in /tmp/gh-aw/aw-*.patch; do\n", " [ -f \"$f\" ] && cp \"$f\" /tmp/gh-aw/threat-detection/ 2>/dev/null || true\n", diff --git a/pkg/workflow/threat_detection_test.go b/pkg/workflow/threat_detection_test.go index 8577be2b04b..5c0ebba47bc 100644 --- a/pkg/workflow/threat_detection_test.go +++ b/pkg/workflow/threat_detection_test.go @@ -955,6 +955,26 @@ func TestDetectionGuardStepCondition(t *testing.T) { } } +func TestPrepareDetectionFilesStepWarnsWhenPromptContextMissingOrEmpty(t *testing.T) { + compiler := NewCompiler() + + steps := compiler.buildPrepareDetectionFilesStep() + if len(steps) == 0 { + t.Fatal("Expected non-empty prepare detection files steps") + } + + joined := strings.Join(steps, "") + if !strings.Contains(joined, "if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then") { + t.Error("Expected prepare step to check for missing or empty detection context prompt") + } + if !strings.Contains(joined, "ERR_VALIDATION: Missing or empty detection context prompt") { + t.Error("Expected prepare step to emit actionable ERR_VALIDATION warning when prompt context is missing") + } + if !strings.Contains(joined, "Detection will continue with fallback workflow context.") { + t.Error("Expected prepare step warning to document fallback behavior") + } +} + // TestDetectionJobLevelCondition verifies that the detection job-level `if:` condition // skips the job entirely when the agent produced no outputs and no patch. // This prevents the detection job from wasting a runner and ensures safe_outputs is