Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Keep missing-verdict warnings visible and avoid unresolved detection …
…model aliases

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 7, 2026
commit 35d1d77511c18a831a154524cc573d7696164198
7 changes: 6 additions & 1 deletion pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ var notifyCommentLog = logger.New("workflow:notify_comment")
// This job runs when:
// 1. always() - runs even if agent fails
// 2. Agent job was not skipped
// 3. NO add_comment output was produced by the agent (avoids duplicate updates)
// 3. NO add_comment output was produced, or detection produced no verdict
// This job depends on all safe output jobs to ensure it runs last
func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, safeOutputJobNames []string) (*Job, error) {
notifyCommentLog.Printf("Building conclusion job: main_job=%s, safe_output_jobs_count=%d", mainJobName, len(safeOutputJobNames))
Expand Down Expand Up @@ -84,9 +84,13 @@ func (c *Compiler) buildConclusionJobSteps(data *WorkflowData, mainJobName strin
// Only add the conclusion update step if status comments are explicitly enabled
if data.StatusComment != nil && *data.StatusComment {
var token string
var stepCondition string
if data.SafeOutputs != nil && data.SafeOutputs.AddComments != nil {
token = data.SafeOutputs.AddComments.GitHubToken
}
if slices.Contains(safeOutputJobNames, "add_comment") {
stepCondition = "always() && !(needs.add_comment.outputs.comment_id)"
}
steps = append(steps, c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Update reaction comment with completion status",
StepID: "conclusion",
Expand All @@ -95,6 +99,7 @@ func (c *Compiler) buildConclusionJobSteps(data *WorkflowData, mainJobName strin
Script: getNotifyCommentErrorScript(),
ScriptFile: "notify_comment_error.cjs",
CustomToken: token,
StepCondition: stepCondition,
})...)
}
steps = append(steps, c.buildConclusionSteeringIssueStep(data, mainJobName, steeringToken)...)
Expand Down
23 changes: 21 additions & 2 deletions pkg/workflow/notify_comment_conclusion_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import (

const conclusionWorkQueueActivationArtifactDir = "${{ runner.temp }}/gh-aw-activation"

func buildDetectionNoVerdictCondition() ConditionNode {
job := "needs." + string(constants.DetectionJobName)
reason := BuildPropertyAccess(job + ".outputs.detection_reason")
return BuildAnd(
BuildNotEquals(BuildPropertyAccess(job+".result"), BuildStringLiteral("skipped")),
BuildOr(
BuildEquals(reason, BuildStringLiteral("agent_failure")),
BuildOr(
BuildEquals(reason, BuildStringLiteral("parse_error")),
BuildEquals(BuildPropertyAccess(job+".outputs.detection_conclusion"), BuildStringLiteral("")),
),
),
)
}

// buildConclusionSetupSteps extracts the common setup, token minting, and artifact steps.
func (c *Compiler) buildConclusionSetupSteps(data *WorkflowData) []string {
var steps []string
Expand Down Expand Up @@ -58,7 +73,7 @@ func (c *Compiler) buildConclusionSetupSteps(data *WorkflowData) []string {
if IsDetectionJobEnabled(data.SafeOutputs) {
steps = append(steps,
" - name: Warn if threat detection produced no verdict\n",
" if: always() && needs.detection.result != 'skipped' && (needs.detection.outputs.detection_reason == 'agent_failure' || needs.detection.outputs.detection_reason == 'parse_error' || needs.detection.outputs.detection_conclusion == '')\n",
fmt.Sprintf(" if: %s\n", RenderCondition(BuildAnd(BuildFunctionCall("always"), buildDetectionNoVerdictCondition()))),
" run: echo \"::warning::Threat detection produced no security verdict. Review the detection job logs before trusting the agent outputs.\"\n",
)
steps = append(steps, buildDetectionArtifactDownloadSteps(artifactPrefixExprForDownstreamJob(data), c.getActionPin)...)
Expand Down Expand Up @@ -590,7 +605,11 @@ func (c *Compiler) buildConclusionJobCondition(data *WorkflowData, mainJobName s
condition := BuildAnd(alwaysFunc, BuildOr(agentNotSkipped, activationGuardrailsFailed))

if slices.Contains(safeOutputJobNames, "add_comment") {
return BuildAnd(condition, &NotNode{Child: BuildPropertyAccess("needs.add_comment.outputs.comment_id")})
noComment := ConditionNode(&NotNode{Child: BuildPropertyAccess("needs.add_comment.outputs.comment_id")})
if IsDetectionJobEnabled(data.SafeOutputs) {
noComment = BuildOr(noComment, buildDetectionNoVerdictCondition())
}
return BuildAnd(condition, noComment)
}
return condition
}
Expand Down
13 changes: 13 additions & 0 deletions pkg/workflow/threat_detection_engine_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ func TestConclusionWarnsWhenDetectionHasNoVerdict(t *testing.T) {
if !strings.Contains(steps, want) {
t.Errorf("expected conclusion steps to contain %q", want)
}
data.SafeOutputs.AddComments = &AddCommentsConfig{}
enabled := true
data.StatusComment = &enabled
job, err := compiler.buildConclusionJob(data, "agent", []string{"add_comment"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(job.If, "!(needs.add_comment.outputs.comment_id) || needs.detection.result != 'skipped'") {
t.Fatal("missing verdict must allow conclusion even after publishing a comment")
}
if !strings.Contains(strings.Join(job.Steps, ""), "if: always() && !(needs.add_comment.outputs.comment_id)") {
t.Fatal("status-comment update must still suppress duplicate comments")
}
}
data.SafeOutputs.ThreatDetection = nil
steps = strings.Join(compiler.buildConclusionSetupSteps(data), "")
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/threat_detection_external.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (c *Compiler) buildPrepareDetectionEngineConfigForExternalDetectorStep(data
codexLogsDir = path.Join(codexHomeDir, "logs")
}
detectionData := buildExternalDetectorWorkflowData(data, "codex")
detectionData.Model = inheritedDetectionModel(data)
detectionData.Model = inheritedDetectionModel(data, engineID)
if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil && data.SafeOutputs.ThreatDetection.Model != "" {
detectionData.Model = data.SafeOutputs.ThreatDetection.Model
}
Expand Down Expand Up @@ -462,7 +462,7 @@ func (c *Compiler) buildExternalDetectorExecutionStep(data *WorkflowData) []stri
// ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || ... || 'auto' }}, and when no org var
// is set COPILOT_MODEL is 'auto'. The AWF API proxy has no pricing for 'auto' and
// returns HTTP 400, causing every inference attempt to fail.
resolvedDetectionModel := inheritedDetectionModel(data)
resolvedDetectionModel := inheritedDetectionModel(data, engineID)
if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil && data.SafeOutputs.ThreatDetection.Model != "" {
resolvedDetectionModel = data.SafeOutputs.ThreatDetection.Model
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ func TestDetectionProviderSettingsIsolatedAcrossEngines(t *testing.T) {
}
t.Run(mainEngine+" to "+detectionEngine, func(t *testing.T) {
data := &WorkflowData{
AI: mainEngine,
AI: mainEngine,
Model: "main-alias",
EngineConfig: &EngineConfig{
ID: mainEngine,
APITarget: "main-provider.example.com",
Expand All @@ -32,7 +33,8 @@ func TestDetectionProviderSettingsIsolatedAcrossEngines(t *testing.T) {
},
},
ModelMappings: map[string][]string{
"sonnet": {"main-only-model"},
"sonnet": {"main-only-model"},
"main-alias": {"main-only-model"},
},
SafeOutputs: &SafeOutputsConfig{
ThreatDetection: &ThreatDetectionConfig{
Expand Down Expand Up @@ -71,7 +73,7 @@ func TestDetectionProviderSettingsIsolatedAcrossEngines(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
steps := strings.Join(build(data), "")
for _, forbidden := range []string{"main-provider.example.com", "MAIN_PROVIDER_KEY", "main-only", "main-only-model"} {
for _, forbidden := range []string{"main-provider.example.com", "MAIN_PROVIDER_KEY", "main-only", "main-only-model", "main-alias"} {
if strings.Contains(steps, forbidden) {
t.Errorf("main provider setting %q leaked into detection", forbidden)
}
Expand Down
6 changes: 5 additions & 1 deletion pkg/workflow/threat_detection_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,15 @@ func engineCoreSecretVarNames(engineID string) []string {
// interpret it. Workflows using a custom engine run detection on a built-in engine that
// does not understand the custom engine's model IDs, so their model is not inherited and
// the detection engine's own default model is used instead.
func inheritedDetectionModel(data *WorkflowData) string {
func inheritedDetectionModel(data *WorkflowData, detectionEngineID string) string {
if data == nil {
return ""
}
engineID := ResolveEngineID(data)
// Cross-engine detection uses built-in aliases, not the main engine's alias definitions.
if engineID != detectionEngineID && len(data.ModelMappings[data.Model]) > 0 {
return ""
}
if engineID == "" || engineID == "pi" || isThreatDetectionCapableEngineID(engineID) {
return data.Model
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/threat_detection_inline_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (c *Compiler) buildDetectionEngineExecutionStep(data *WorkflowData) []strin
detectionEngineConfig.HarnessMaxRetries = "0"
}

resolvedDetectionModel := inheritedDetectionModel(data)
resolvedDetectionModel := inheritedDetectionModel(data, engineSetting)
if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil && data.SafeOutputs.ThreatDetection.Model != "" {
resolvedDetectionModel = data.SafeOutputs.ThreatDetection.Model
}
Expand Down
Loading