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
Add opt-in local-only Docker image validation via frontmatter
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 6, 2026
commit 5f69c8a775d02670e2bceb303462a41d5bd0f077
73 changes: 73 additions & 0 deletions actions/setup/sh/download_docker_images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,79 @@ set +o histexpand

set -euo pipefail

case "${GH_AW_DOCKER_IMAGE_PULL_POLICY-always}" in
always) ;;
never)
if (( $# == 0 )); then
echo "Local-only image validation requires at least one image" >&2
exit 1
fi

declare -A alias_digests=()
declare -a aliases=()
declare -a expected_digests=()
for image in "$@"; do
if [[ ! "$image" =~ ^([a-z0-9][a-z0-9.-]*(:[0-9]+)?/([a-z0-9]+([._-][a-z0-9]+)*/)*[a-z0-9]+([._-][a-z0-9]+)*)(:([A-Za-z0-9_][A-Za-z0-9_.-]*))?@sha256:([a-f0-9]{64})$ ]]; then
echo "Local-only mode requires a fully qualified repository[:tag]@sha256:<64 lowercase hex> reference: $image" >&2
exit 1
fi
repository="${BASH_REMATCH[1]}"
tag="${BASH_REMATCH[7]:-latest}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] When a digest reference omits a tag (e.g. repo@sha256:...), tag defaults to latest, so the script tags it as repository:latest. This contradicts the new docs' claim that "a versioned reference creates only its versioned alias, not :latest" and silently produces a floating-tag alias in a mode whose entire purpose is pinned, non-floating references.

💡 Suggested fix

Either reject untagged digest references in never mode (require an explicit tag) or document/test the :latest fallback explicitly so behavior matches the stated guarantee. The current test suite (download_docker_images_local_test.sh) only exercises explicitly-tagged references, so this gap isn't caught.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local-only mode now rejects digest references without an explicit tag, preventing a :latest alias; added a regression case. Fixed in 61a19e3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untagged digest reference silently gets a :latest alias, contradicting the docs

When a never-mode image reference omits an explicit tag (e.g. repo@sha256:...), tag defaults to latest (line 45: tag="${BASH_REMATCH[7]:-latest}"), so the alias created is repository:latest.

This directly contradicts the new docs added in this PR (docs/src/content/docs/reference/frontmatter.md): "A versioned reference creates only its versioned alias, not :latest". In local-only mode — whose entire purpose is avoiding floating/mutable tags — an untagged digest silently produces a floating :latest alias.

The new test suite (download_docker_images_local_test.sh) only exercises explicitly-tagged references, so this gap isn't caught by the added coverage.

Suggested fix: either reject untagged digest references in never mode (require an explicit tag, matching the stated guarantee), or explicitly document/test that untagged references fall back to :latest so behavior matches what's promised.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local-only mode now rejects digest references without an explicit tag, preventing a :latest alias; added a regression case. Fixed in 61a19e3.

digest="${BASH_REMATCH[8]}"
registry="${repository%%/*}"
if [[ "$registry" != *.* && "$registry" != *:* && "$registry" != localhost ]]; then
echo "Local-only mode requires a registry-qualified image: $image" >&2
exit 1
fi
alias="${repository}:${tag}"
if [[ -v alias_digests["$alias"] && "${alias_digests[$alias]}" != "$digest" ]]; then
echo "Conflicting digests requested for alias $alias" >&2
exit 1
fi
alias_digests["$alias"]="$digest"
aliases+=("$alias")
expected_digests+=("${repository}@sha256:${digest}")
done

# Validate every exact repository digest before modifying any local tag.
index=0
for image in "$@"; do
expected="${expected_digests[$index]}"
if ! digests=$(timeout 30s docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image"); then
echo "Local-only image inspection failed: $image" >&2
exit 1
fi
found=false
while IFS= read -r entry; do
if [[ "$entry" == "$expected" ]]; then
found=true
break
fi
done <<< "$digests"
if [[ "$found" != true ]]; then
echo "Local-only image is missing exact RepoDigest $expected" >&2
exit 1
fi
index=$((index + 1))
done

index=0
for image in "$@"; do
if ! timeout 30s docker tag "$image" "${aliases[$index]}"; then
echo "Local-only image tagging failed: $image" >&2
exit 1
fi
index=$((index + 1))
done
echo "All local images validated and tagged successfully"
exit 0
;;
*)
echo "Invalid GH_AW_DOCKER_IMAGE_PULL_POLICY (expected always or never)" >&2
exit 1
;;
esac

# Helper function to pull Docker images with retry logic
docker_pull_with_retry() {
local image="$1"
Expand Down
105 changes: 105 additions & 0 deletions actions/setup/sh/download_docker_images_local_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env bash

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added download_docker_images_local_test.sh to make test-scripts; the target passes. Fixed in 61a19e3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] This mock-Docker regression suite isn't invoked anywhere — make test-scripts enumerates shell tests explicitly (lines 495-505) and doesn't include this file, so CI can pass without ever exercising the fail-closed helper's validation logic.

💡 Suggested fix

Add a line to the test-scripts target in Makefile:

bash actions/setup/sh/download_docker_images_local_test.sh

Without this, the comprehensive edge-case coverage (conflicting digests, inspect failures, tag failures, uppercase digest rejection, etc.) written in this PR provides no actual safety net.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registered the mock-Docker suite in make test-scripts; the target passes. Fixed in 61a19e3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New mock-Docker regression suite is never run in CI

download_docker_images_local_test.sh (covering conflicting digests, inspect failures, tag failures, uppercase-digest rejection, etc.) is not wired into any Makefile target. make test-scripts (Makefile lines 495–505) lists shell tests explicitly and does not include this file, and it's not referenced from test-all or any CI workflow either.

As a result, CI can pass without ever exercising the new fail-closed local-only validation logic — the comprehensive edge-case coverage in this file currently provides no safety net.

Suggested fix: add this script to the test-scripts target in the Makefile:

bash actions/setup/sh/download_docker_images_local_test.sh

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registered the mock-Docker suite in make test-scripts; the target passes. Fixed in 61a19e3.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
export DOCKER_LOG="$WORKDIR/docker.log"
export MOCK_DIGESTS="$WORKDIR/digests"

cat > "$WORKDIR/docker" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$DOCKER_LOG"
case "$1" in
image)
[[ "$2" == inspect ]] || exit 1
[[ "${MOCK_INSPECT_FAIL:-}" != 124 ]] || exit 124
[[ "${MOCK_INSPECT_FAIL:-}" != 1 ]] || exit 1
cat "$MOCK_DIGESTS"
;;
tag)
[[ "${MOCK_TAG_FAIL:-}" != 1 ]]
;;
pull)
[[ "${MOCK_PULL_FAIL:-}" != 1 ]]
;;
*) exit 1 ;;
esac
MOCK
chmod +x "$WORKDIR/docker"
export PATH="$WORKDIR:$PATH"

a="$(printf 'a%.0s' {1..64})"
b="$(printf 'b%.0s' {1..64})"
first="registry.example.com:5000/team/app:v1@sha256:$a"
second="registry.example.com:5000/team/other:v2@sha256:$b"
printf 'registry.example.com:5000/team/app@sha256:%s\nregistry.example.com:5000/team/other@sha256:%s\n' "$a" "$b" > "$MOCK_DIGESTS"

run_ok() {
: > "$DOCKER_LOG"
GH_AW_DOCKER_IMAGE_PULL_POLICY=never bash "$SCRIPT_DIR/download_docker_images.sh" "$@" > "$WORKDIR/output" 2>&1 || {
cat "$WORKDIR/output"
exit 1
}
if grep -q '^pull ' "$DOCKER_LOG"; then
echo "Unexpected pull in local-only mode" >&2
exit 1
fi
}

run_fail() {
: > "$DOCKER_LOG"
if GH_AW_DOCKER_IMAGE_PULL_POLICY=never bash "$SCRIPT_DIR/download_docker_images.sh" "$@" > "$WORKDIR/output" 2>&1; then
echo "Expected local-only validation failure: $*" >&2
exit 1
fi
! grep -Eq '^(pull|tag) ' "$DOCKER_LOG"
}

run_ok "$first" "$second"
grep -Fxq "image inspect --format {{range .RepoDigests}}{{println .}}{{end}} $first" "$DOCKER_LOG"
grep -Fxq "tag $first registry.example.com:5000/team/app:v1" "$DOCKER_LOG"
grep -Fxq "tag $second registry.example.com:5000/team/other:v2" "$DOCKER_LOG"
[[ "$(grep -c '^image ' "$DOCKER_LOG")" == 2 ]]
[[ "$(grep -c '^tag ' "$DOCKER_LOG")" == 2 ]]
[[ "$(sed -n '2p' "$DOCKER_LOG")" == image\ inspect* ]]
[[ "$(tail -1 "$DOCKER_LOG")" == "tag $second registry.example.com:5000/team/other:v2" ]]
! grep -q 'app:latest' "$DOCKER_LOG" || exit 1

run_fail
run_fail alpine:latest
run_fail "registry.example.com/team/app:v1"
run_fail "registry.example.com/team/app:v1@sha256:${a^^}"
run_fail $'registry.example.com/team/app:v1\nbad@sha256:'"$a"
run_fail "$first" "registry.example.com:5000/team/app:v1@sha256:$b"
printf 'registry.example.com:5000/team/app@sha256:%s\n' "$b" > "$MOCK_DIGESTS"
run_fail "$first"
printf '%s\n' "registry.example.com:5000/team/app:v1@sha256:$a" > "$MOCK_DIGESTS"
run_fail "$first"
printf 'registry.example.com:5000/team/app@sha256:%s\n' "$a" > "$MOCK_DIGESTS"
MOCK_INSPECT_FAIL=1 run_fail "$first"
MOCK_INSPECT_FAIL=124 run_fail "$first"
printf 'registry.example.com:5000/team/app@sha256:%s\n' "$a" > "$MOCK_DIGESTS"
run_fail "$first" "$second"
MOCK_TAG_FAIL=1 GH_AW_DOCKER_IMAGE_PULL_POLICY=never bash "$SCRIPT_DIR/download_docker_images.sh" "$first" > "$WORKDIR/output" 2>&1 && exit 1
if grep -q '^pull ' "$DOCKER_LOG"; then
echo "Unexpected pull after local-only tagging failure" >&2
exit 1
fi

: > "$DOCKER_LOG"
GH_AW_DOCKER_IMAGE_PULL_POLICY=always bash "$SCRIPT_DIR/download_docker_images.sh" "$first" > "$WORKDIR/output" 2>&1
grep -q '^pull --quiet ' "$DOCKER_LOG"
: > "$DOCKER_LOG"
env -u GH_AW_DOCKER_IMAGE_PULL_POLICY bash "$SCRIPT_DIR/download_docker_images.sh" "$first" > "$WORKDIR/output" 2>&1
grep -q '^pull --quiet ' "$DOCKER_LOG"
for policy in '' sometimes; do
: > "$DOCKER_LOG"
if GH_AW_DOCKER_IMAGE_PULL_POLICY="$policy" bash "$SCRIPT_DIR/download_docker_images.sh" "$first" > "$WORKDIR/output" 2>&1; then
echo "Expected invalid policy failure" >&2
exit 1
fi
[[ ! -s "$DOCKER_LOG" ]]
done
echo "Local-only Docker image tests passed"
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ description: "Description of the workflow"
# (optional)
intent: "example-value"

# Policy for pre-start Docker images in agent and detection jobs. 'always'
# (default) pulls images; 'never' validates exact local RepoDigests and does not
# contact a registry.
# (optional)
docker-image-pull-policy: "always"

# Optional emoji to represent the workflow visually in listings and UI surfaces.
# (optional)
emoji: "example-value"
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ Add deterministic steps before or after agentic execution, or define full custom

The `jobs:` map can also target compiler-generated built-in jobs such as `agent`, `activation`, and `safe_outputs` for additive customization. In particular, `jobs.agent.needs` and `jobs.agent.if` let you gate the generated agent job on a custom setup job while preserving compiler-managed dependencies.

### Pre-start Docker Image Pull Policy (`docker-image-pull-policy:`)

By default, the generated agent and detection jobs pre-pull their Docker images (`docker-image-pull-policy: always`). Set `docker-image-pull-policy: never` to require those images to be present locally instead:

```yaml
docker-image-pull-policy: never
```

In `never` mode, every pre-start image must be a registry-qualified `repository[:tag]@sha256:<64 lowercase hex>` reference. The download helper checks that Docker can inspect each exact reference and that its `RepoDigests` contains the exact `repository@sha256:...` entry. It validates **all** images before tagging their requested local aliases and never falls back to a pull. A versioned reference creates only its versioned alias, not `:latest`; ensure downstream consumers use that alias. Missing metadata, unpinned images, conflicting aliases, or failed inspections stop the job.

Use `jobs.agent.pre-steps` and `jobs.detection.pre-steps` to restore independently verified image content on each isolated consumer runner **before** its download step; if a job depends on a separate acquisition job, configure its `needs` accordingly. Preloading with `docker save/load` is not necessarily sufficient: that transport may discard registry `RepoDigests`, which this mode refuses to fabricate. This setting only checks local Docker metadata; it does not independently verify image bytes, provenance, or scanner policy, or prevent other startup components from contacting registries. Use an isolated disposable Docker daemon and verify the full handoff and startup path separately.

### Cache Configuration (`cache:`)

Cache configuration using standard GitHub Actions `actions/cache` syntax:
Expand Down
20 changes: 20 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ func TestValidateMainWorkflowFrontmatter_MaxToolCalls(t *testing.T) {
}
}

func TestValidateMainWorkflowFrontmatter_DockerImagePullPolicy(t *testing.T) {
for _, tt := range []struct {
policy any
wantErr bool
}{
{policy: "always"},
{policy: "never"},
{policy: "", wantErr: true},
{policy: "sometimes", wantErr: true},
{policy: true, wantErr: true},
} {
err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(map[string]any{
"on": "workflow_dispatch", "docker-image-pull-policy": tt.policy,
}, "workflow.md")
if (err != nil) != tt.wantErr {
t.Errorf("policy %v: error = %v, want error %v", tt.policy, err, tt.wantErr)
}
}
}

func TestValidateMainWorkflowFrontmatter_RejectsUnsupportedTopLevelFields(t *testing.T) {
t.Parallel()

Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
"description": "Optional statement of the durable outcome the workflow exists to achieve. Unlike 'description', which explains what the workflow does, 'intent' explains why the workflow exists and should stay implementation-independent. Rendered as a comment in the generated GitHub Actions YAML file (.lock.yml).",
"examples": ["Reduce manual issue-triage work while keeping classifications evidence-based."]
},
"docker-image-pull-policy": {
"type": "string",
"enum": ["always", "never"],
"description": "Policy for pre-start Docker images in agent and detection jobs. 'always' (default) pulls images; 'never' validates exact local RepoDigests and does not contact a registry."
},
"emoji": {
"type": "string",
"description": "Optional emoji to represent the workflow visually in listings and UI surfaces.",
Expand Down
9 changes: 6 additions & 3 deletions pkg/workflow/docker.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package workflow

import (
"fmt"
"sort"
"strings"

Expand Down Expand Up @@ -288,15 +287,19 @@ func mergeDockerImagePins(existing, newPins []GHAWManifestContainer) []GHAWManif
}

// generateDownloadDockerImagesStep generates the step to download Docker images
func generateDownloadDockerImagesStep(yaml *strings.Builder, dockerImages []string) {
func generateDownloadDockerImagesStep(yaml *strings.Builder, dockerImages []string, localOnly bool) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator now preserves unset, always, and never; explicit values are emitted as step-level overrides. Added compiler/runtime coverage for always. Fixed in 61a19e3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] localOnly collapses two semantically distinct states — "unset" and explicit always" — into the same boolean, and the generated step emits no override env var for either. This means a user who sets a workflow-level env: GH_AW_DOCKER_IMAGE_PULL_POLICY: never(expecting the compiler-generated step to inherit it) gets silently overridden back toalwaysbehavior only whendocker-image-pull-policyis explicitlynever` — the interface doesn't make the precedence/interaction between the new frontmatter key and the existing env var observable.

💡 Suggested improvement

Consider threading a tri-state policy (unset/always/never) through instead of a boolean, and document/test what happens if a user sets the env var directly via env: frontmatter alongside (or instead of) docker-image-pull-policy. Right now there's no test covering that interaction, so behavior there is undefined-by-implementation rather than by design.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator now preserves all three policy states and explicitly emits always/never, leaving unset absent so inherited env remains observable. Added coverage for both agent and detection steps. Fixed in 61a19e3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localOnly boolean collapses "unset" and explicit always" into one state, hiding a precedence bug with the env var

generateDownloadDockerImagesStep only emits env: GH_AW_DOCKER_IMAGE_PULL_POLICY: never when docker-image-pull-policy == "never"; otherwise it emits no override at all (both when the frontmatter key is absent and when it's explicitly "always").

Since the shell script reads GH_AW_DOCKER_IMAGE_PULL_POLICY from the step env: (falling back to always only when unset), a user who sets a job/workflow-level env: GH_AW_DOCKER_IMAGE_PULL_POLICY: never (e.g. via jobs.agent.env or top-level env:) expecting the generated step to inherit it will have that value silently overridden back to the default always pull behavior whenever docker-image-pull-policy isn't explicitly "never" in frontmatter — defeating the local-only guarantee without any error.

Suggested fix: thread a tri-state policy (unset / "always" / "never") instead of a boolean, and explicitly emit GH_AW_DOCKER_IMAGE_PULL_POLICY: always when docker-image-pull-policy: "always" is configured, leaving the env line absent only when the frontmatter key is truly unset. Add a test for the interaction between frontmatter docker-image-pull-policy and a directly-set GH_AW_DOCKER_IMAGE_PULL_POLICY env var.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator now preserves all three policy states and explicitly emits always/never, leaving unset absent so inherited env remains observable. Added coverage for both agent and detection steps. Fixed in 61a19e3.

if len(dockerImages) == 0 {
return
}

yaml.WriteString(" - name: Download container images\n")
if localOnly {
yaml.WriteString(" env:\n GH_AW_DOCKER_IMAGE_PULL_POLICY: never\n")
}
yaml.WriteString(" run: bash \"${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh\"")
for _, image := range dockerImages {
fmt.Fprintf(yaml, " %s", image)
yaml.WriteString(" ")
yaml.WriteString(image)
}
yaml.WriteString("\n")
}
19 changes: 19 additions & 0 deletions pkg/workflow/docker_predownload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,23 @@ func TestDockerImagePredownload(t *testing.T) {
expectedImages []string
manifestImages []string
expectStep bool
localOnly bool
}{
{
name: "Local-only policy reaches agent download step",
frontmatter: `---
on: issues
engine: claude
docker-image-pull-policy: never
tools:
github:
---

# Test
Test workflow.`,
expectStep: true,
localOnly: true,
},
{
name: "GitHub tool generates image download step",
frontmatter: `---
Expand Down Expand Up @@ -135,6 +151,9 @@ Test workflow - safe outputs MCP server without GitHub tool.`,

// If we expect a step, verify the images are present
if tt.expectStep {
if got := strings.Contains(string(yaml), "GH_AW_DOCKER_IMAGE_PULL_POLICY: never"); got != tt.localOnly {
t.Errorf("local-only download policy present = %v, want %v", got, tt.localOnly)
}
// Verify the script call is present
if !strings.Contains(string(yaml), "bash \"${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh\"") {
t.Error("Expected to find 'bash \"${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh\"' script call in generated YAML")
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/mcp_setup_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,

// Collect all Docker images that will be used and generate download step
dockerImages := collectDockerImages(tools, workflowData, c.actionMode)
generateDownloadDockerImagesStep(yaml, dockerImages)
generateDownloadDockerImagesStep(yaml, dockerImages, workflowData.RawFrontmatter["docker-image-pull-policy"] == "never")

// If no MCP tools, skip setup unless the engine still needs MCP gateway/config bootstrap.
// Codex with AWF firewall enabled requires MCP config generation to set its OpenAI proxy
Expand Down
5 changes: 4 additions & 1 deletion pkg/workflow/threat_detection_external.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func buildThreatDetectionWorkflowData(data *WorkflowData, engineID string) *Work
},
},
}
if data.RawFrontmatter["docker-image-pull-policy"] == "never" {
detectionData.RawFrontmatter = map[string]any{"docker-image-pull-policy": "never"}
}

if firewallConfig := getFirewallConfig(data); firewallConfig != nil {
firewallCopy := *firewallConfig
Expand Down Expand Up @@ -206,7 +209,7 @@ func (c *Compiler) buildPullAWFContainersStep(data *WorkflowData) []string {
threatLog.Printf("Pre-pulling %d AWF container image(s) for detection job", len(images))

var b strings.Builder
generateDownloadDockerImagesStep(&b, images)
generateDownloadDockerImagesStep(&b, images, data.RawFrontmatter["docker-image-pull-policy"] == "never")
if b.Len() == 0 {
return nil
}
Expand Down
30 changes: 30 additions & 0 deletions pkg/workflow/threat_detection_job_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@ import (
"github.com/github/gh-aw/pkg/constants"
)

func TestDockerImagePullPolicySteps(t *testing.T) {
var step strings.Builder
generateDownloadDockerImagesStep(&step, []string{"registry.example.com/app:v1"}, true)
if !strings.Contains(step.String(), "GH_AW_DOCKER_IMAGE_PULL_POLICY: never") {
t.Fatalf("agent download step missing local-only policy: %s", step.String())
}
step.Reset()
generateDownloadDockerImagesStep(&step, []string{"registry.example.com/app:v1"}, false)
if strings.Contains(step.String(), "GH_AW_DOCKER_IMAGE_PULL_POLICY") {
t.Fatalf("default download step must not override policy: %s", step.String())
}

data := &WorkflowData{
AI: "codex",
RawFrontmatter: map[string]any{"docker-image-pull-policy": "never"},
SafeOutputs: &SafeOutputsConfig{ThreatDetection: &ThreatDetectionConfig{}},
Features: map[string]any{string(constants.GHAWDetectionFeatureFlag): true},
SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Type: SandboxTypeAWF}},
}
steps := strings.Join(NewCompiler().buildDetectionJobSteps(data), "")
if !strings.Contains(steps, "GH_AW_DOCKER_IMAGE_PULL_POLICY: never") {
t.Fatalf("detection download step missing local-only policy: %s", steps)
}
data.Features[string(constants.GHAWDetectionFeatureFlag)] = false
steps = strings.Join(NewCompiler().buildDetectionJobSteps(data), "")
if !strings.Contains(steps, "GH_AW_DOCKER_IMAGE_PULL_POLICY: never") {
t.Fatalf("inline detection download step missing local-only policy: %s", steps)
}
}

func TestBuildDetectionJobStepsCodexAvoidsDuplicateContainerPullStep(t *testing.T) {
compiler := NewCompiler()

Expand Down
Loading