Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions actions/setup/js/setup_threat_detection.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down
83 changes: 83 additions & 0 deletions actions/setup/js/setup_threat_detection.test.cjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
3 changes: 3 additions & 0 deletions pkg/workflow/threat_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions pkg/workflow/threat_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down