From 691be22aba56d326aad8123eadd6960668c0f38d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:14:25 +0000 Subject: [PATCH 1/4] Add grant compile plumbing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- cmd/gh-aw/compile_flags_test.go | 8 + cmd/gh-aw/main.go | 3 + .../docs/reference/compilation-process.md | 2 +- .../docs/reference/gh-aw-as-mcp-server.md | 4 +- docs/src/content/docs/setup/cli.md | 3 +- pkg/cli/compile_config.go | 1 + pkg/cli/compile_external_tools.go | 7 + pkg/cli/compile_pipeline.go | 32 +++ pkg/cli/docker_images.go | 11 +- pkg/cli/docker_images_test.go | 26 +- pkg/cli/grant.go | 259 ++++++++++++++++++ pkg/cli/grant_test.go | 75 +++++ pkg/cli/mcp_server_defaults_test.go | 1 + pkg/cli/mcp_tools_readonly.go | 13 +- 14 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 pkg/cli/grant.go create mode 100644 pkg/cli/grant_test.go diff --git a/cmd/gh-aw/compile_flags_test.go b/cmd/gh-aw/compile_flags_test.go index 7111c027359..b846b513844 100644 --- a/cmd/gh-aw/compile_flags_test.go +++ b/cmd/gh-aw/compile_flags_test.go @@ -28,4 +28,12 @@ func TestCompileCommandShortFlags(t *testing.T) { if noModelsDevLookupFlag.DefValue != "false" { t.Fatalf("expected --no-models-dev-lookup default to be false, got %s", noModelsDevLookupFlag.DefValue) } + + grantFlag := compileCmd.Flags().Lookup("grant") + if grantFlag == nil { + t.Fatal("expected --grant flag on compile command") + } + if grantFlag.DefValue != "false" { + t.Fatalf("expected --grant default to be false, got %s", grantFlag.DefValue) + } } diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 784611c5410..11b810016ec 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -323,6 +323,7 @@ Unlike ` + "`gh aw upgrade`" + `, ` + "`gh aw compile`" + ` only applies codemod actionlint, _ := cmd.Flags().GetBool("actionlint") runnerGuard, _ := cmd.Flags().GetBool("runner-guard") grype, _ := cmd.Flags().GetBool("grype") + grant, _ := cmd.Flags().GetBool("grant") jsonOutput, _ := cmd.Flags().GetBool("json") showAllErrors, _ := cmd.Flags().GetBool("show-all") fix, _ := cmd.Flags().GetBool("fix") @@ -389,6 +390,7 @@ Unlike ` + "`gh aw upgrade`" + `, ` + "`gh aw compile`" + ` only applies codemod Actionlint: actionlint, RunnerGuard: runnerGuard, Grype: grype, + Grant: grant, JSONOutput: jsonOutput, ShowAllErrors: showAllErrors, Stats: stats, @@ -748,6 +750,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all compileCmd.Flags().Bool("actionlint", false, "Run actionlint linter on generated .lock.yml files") compileCmd.Flags().Bool("runner-guard", false, "Run runner-guard taint analysis scanner on generated .lock.yml files (uses Docker image "+cli.RunnerGuardImage+")") compileCmd.Flags().Bool("grype", false, "Run grype vulnerability scanner on container images referenced in compiled .lock.yml files (uses Docker image "+cli.GrypeImage+")") + compileCmd.Flags().Bool("grant", false, "Run grant license scanner on container images referenced in compiled .lock.yml files (uses Docker image "+cli.GrantImage+")") compileCmd.Flags().Bool("fix", false, "Apply automatic codemod fixes to workflows before compiling") compileCmd.Flags().BoolP("json", "j", false, "Output results in JSON format") compileCmd.Flags().Bool("show-all", false, "Display all compilation errors instead of only the highest-priority subset (default: top 5)") diff --git a/docs/src/content/docs/reference/compilation-process.md b/docs/src/content/docs/reference/compilation-process.md index 04dd0426a3b..a0b75c754b1 100644 --- a/docs/src/content/docs/reference/compilation-process.md +++ b/docs/src/content/docs/reference/compilation-process.md @@ -228,7 +228,7 @@ Pre-activation runs gating checks sequentially before any AI execution. Any fail | `gh aw compile --verbose` | Enable verbose output | | `gh aw compile --strict` | Enhanced security validation | | `gh aw compile --no-emit` | Validate without generating files | -| `gh aw compile --actionlint --zizmor --poutine` | Run security scanners | +| `gh aw compile --actionlint --zizmor --poutine --grant` | Run security scanners | | `gh aw compile --purge` | Remove orphaned `.lock.yml` files | | `gh aw compile --output /path/to/output` | Custom output directory | | `gh aw compile --action-mode action --actions-repo owner/repo` | Compile using a custom actions repository (requires `--action-mode action`) | diff --git a/docs/src/content/docs/reference/gh-aw-as-mcp-server.md b/docs/src/content/docs/reference/gh-aw-as-mcp-server.md index d261814384b..69c25058ba1 100644 --- a/docs/src/content/docs/reference/gh-aw-as-mcp-server.md +++ b/docs/src/content/docs/reference/gh-aw-as-mcp-server.md @@ -130,13 +130,13 @@ Compile Markdown workflows to GitHub Actions YAML with optional static analysis. - `workflows` (optional): Array of workflow files to compile (empty for all) - `strict` (optional): Enforce strict mode validation (default: true) - `fix` (optional): Apply automatic codemod fixes before compiling -- `zizmor`, `poutine`, `actionlint` (optional): Run security scanners/linters +- `zizmor`, `poutine`, `actionlint`, `grant` (optional): Run security scanners/linters - `jq` (optional): Apply jq filter to JSON output Returns a JSON array with `workflow`, `valid`, `errors`, `warnings`, and `compiled_file` fields. > [!NOTE] -> The `actionlint`, `zizmor`, and `poutine` scanners use Docker images that download on first use. If images are still being pulled, the tool returns a "Docker images are being downloaded. Please wait and retry the compile command." message. Wait 15–30 seconds, then retry the request. +> The `actionlint`, `zizmor`, `poutine`, and `grant` scanners use Docker images that download on first use. If images are still being pulled, the tool returns a "Docker images are being downloaded. Please wait and retry the compile command." message. Wait 15–30 seconds, then retry the request. ### `logs` diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index d805e2e61e7..1207545d1af 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -321,6 +321,7 @@ gh aw compile --validate --strict # Schema + strict mode validation gh aw compile --fix # Run fix before compilation gh aw compile --zizmor # Security scan (warnings) gh aw compile --strict --zizmor # Security scan (fails on findings) +gh aw compile --grant # License scan container images gh aw compile --dependabot # Generate dependency manifests gh aw compile --purge # Remove orphaned .lock.yml files ``` @@ -329,7 +330,7 @@ If the repository root contains an [`aw.yml` manifest](/gh-aw/reference/aw-yml-p Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `--fix`. -**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--gh-aw-ref`, `--ghes`, `--json/-j`, `--logical-repo/-l`, `--no-check-update`, `--no-emit`, `--no-models-dev-lookup`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--show-all`, `--staged`, `--stats`, `--strict`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--zizmor` +**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--gh-aw-ref`, `--ghes`, `--grant`, `--json/-j`, `--logical-repo/-l`, `--no-check-update`, `--no-emit`, `--no-models-dev-lookup`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--show-all`, `--staged`, `--stats`, `--strict`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--zizmor` **`--gh-aw-ref` flag:** Convenience alias for `--action-mode release --action-tag `. Accepts a branch name, tag, or commit SHA targeting the `github/gh-aw` repository. Branch and tag names are resolved to their full commit SHA at compile time, so the baked-in reference is immutable and reproducible. Useful for E2E-testing workflows compiled against a specific gh-aw revision. diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 023ff1dfb62..97e34d9597f 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -26,6 +26,7 @@ type CompileConfig struct { Actionlint bool // Run actionlint linter on generated .lock.yml files RunnerGuard bool // Run runner-guard taint analysis scanner on generated .lock.yml files Grype bool // Run grype vulnerability scanner on container images referenced in compiled .lock.yml files + Grant bool // Run grant license scanner on container images referenced in compiled .lock.yml files JSONOutput bool // Output validation results as JSON ShowAllErrors bool // Display all prioritized errors instead of the default top five ActionMode string // How action scripts are referenced: dev, release, or action. Auto-detected if empty. diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go index de256aad167..2bfbdd8b48d 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -18,6 +18,7 @@ // - RunZizmorOnFiles() - Run zizmor on multiple lock files // - RunPoutineOnDirectory() - Run poutine security scanner on a directory // - RunRunnerGuardOnDirectory() - Run runner-guard taint analysis on a directory +// - RunGrantOnLockFiles() - Run grant license scanning on container images package cli @@ -65,6 +66,12 @@ func RunGrypeOnLockFiles(lockFiles []string, verbose bool, strict bool) error { return runBatchLockFileTool("grype", lockFiles, verbose, strict, runGrypeOnLockFiles) } +// RunGrantOnLockFiles runs the grant license scanner on container images extracted +// from the gh-aw-manifest headers in the provided lock files. +func RunGrantOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + return runBatchLockFileTool("grant", lockFiles, verbose, strict, runGrantOnLockFiles) +} + // runBatchLockFileTool runs a batch tool on lock files with uniform error handling func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error { if len(lockFiles) == 0 { diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index ce735ebc086..40cc9707b28 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -64,6 +64,7 @@ func compileSpecificFiles( var lockFilesForZizmor []string var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForGrype []string // lock files for grype container image vulnerability scanning + var lockFilesForGrant []string // lock files for grant container image license scanning // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -152,6 +153,9 @@ func compileSpecificFiles( if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } + if config.Grant { + lockFilesForGrant = append(lockFilesForGrant, fileResult.lockFile) + } } } } @@ -221,6 +225,18 @@ func compileSpecificFiles( return workflowDataList, err } } + + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } } // Get warning count from compiler @@ -319,6 +335,7 @@ func compileAllFilesInDirectory( var lockFilesForZizmor []string var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForGrype []string // lock files for grype container image vulnerability scanning + var lockFilesForGrant []string // lock files for grant container image license scanning for _, file := range mdFiles { // Respect context cancellation between files (e.g. Ctrl+C) @@ -376,6 +393,9 @@ func compileAllFilesInDirectory( if config.Grype { lockFilesForGrype = append(lockFilesForGrype, fileResult.lockFile) } + if config.Grant { + lockFilesForGrant = append(lockFilesForGrant, fileResult.lockFile) + } } } } @@ -441,6 +461,18 @@ func compileAllFilesInDirectory( return workflowDataList, err } } + + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } } // Emit recommendation when many slash commands are present without centralized strategy. diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index e20e38bf478..a307c83a8e0 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -34,6 +34,7 @@ const ( ActionlintImage = "rhysd/actionlint:1.7.12" RunnerGuardImage = "ghcr.io/vigilant-llc/runner-guard:latest" GrypeImage = "anchore/grype:latest" + GrantImage = "anchore/grant:latest" ) // dockerPullState tracks the state of docker pull operations @@ -225,9 +226,9 @@ func StartDockerImageDownload(ctx context.Context, image string) bool { // Returns: // - nil if all required images are available // - error if Docker is unavailable or images are downloading/need to be downloaded -func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useGrype bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard, useGrype, useGrant bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useGrype { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard && !useGrype && !useGrant { return nil } @@ -260,6 +261,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useGrant { + tool := "grant" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } verb := "requires" if len(requestedTools) > 1 { verb = "require" @@ -283,6 +289,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {useActionlint, ActionlintImage, "actionlint"}, {useRunnerGuard, RunnerGuardImage, "runner-guard"}, {useGrype, GrypeImage, "grype"}, + {useGrant, GrantImage, "grant"}, } for _, img := range imagesToCheck { diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index 39cc5895f6d..ae1fa6f07ec 100644 --- a/pkg/cli/docker_images_test.go +++ b/pkg/cli/docker_images_test.go @@ -15,7 +15,7 @@ func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) { ResetDockerPullState() // When no tools are requested, should return nil - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested, got: %v", err) } @@ -31,7 +31,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyDownloading(t *testing.T) { SetDockerImageDownloading(ZizmorImage, true) // Should return an error indicating to retry - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Error("Expected error when image is downloading, got nil") } @@ -107,6 +107,9 @@ func TestDockerImageConstants(t *testing.T) { if GrypeImage == "" { t.Error("GrypeImage constant should not be empty") } + if GrantImage == "" { + t.Error("GrantImage constant should not be empty") + } // Verify they are docker image references expectedImages := map[string]string{ @@ -115,6 +118,7 @@ func TestDockerImageConstants(t *testing.T) { "actionlint": ActionlintImage, "runner-guard": RunnerGuardImage, "grype": GrypeImage, + "grant": GrantImage, } for name, image := range expectedImages { @@ -138,7 +142,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -164,7 +168,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -199,7 +203,7 @@ func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) { // when the image is marked as downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -223,7 +227,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyAvailable(t *testing.T) { SetMockImageAvailable(ZizmorImage, true) // Should not return an error since the image is available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -530,7 +534,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) { SetMockDockerAvailable(false) // Should return a clear error about Docker not being available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -568,7 +572,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -607,7 +611,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_NoTools(t *testing.T) { SetMockDockerAvailable(false) // When no tools requested, should return nil even if Docker is unavailable - err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested (even with Docker unavailable), got: %v", err) } @@ -639,7 +643,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -668,7 +672,7 @@ func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) { SetDockerImageDownloading(RunnerGuardImage, true) // Request all tools, including runner-guard - err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true, false, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } diff --git a/pkg/cli/grant.go b/pkg/cli/grant.go new file mode 100644 index 00000000000..d3b6b82704a --- /dev/null +++ b/pkg/cli/grant.go @@ -0,0 +1,259 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/logger" +) + +var grantLog = logger.New("cli:grant") + +const grantPolicyFilename = ".grant.yaml" + +type grantOutput struct { + Tool string `json:"tool"` + Run struct { + Targets []grantTargetResult `json:"targets"` + } `json:"run"` +} + +type grantTargetResult struct { + Source struct { + Ref string `json:"ref"` + } `json:"source"` + Evaluation grantTargetEvaluation `json:"evaluation"` +} + +type grantTargetEvaluation struct { + Status string `json:"status"` + Findings struct { + Packages []grantPackageFinding `json:"packages"` + } `json:"findings"` +} + +type grantPackageFinding struct { + Name string `json:"name"` + Version string `json:"version"` + Decision string `json:"decision"` + Licenses []grantLicenseDetail `json:"licenses"` +} + +type grantLicenseDetail struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// runGrantOnLockFiles extracts container image references from the gh-aw-manifest +// headers in the provided lock files, deduplicates them, and runs the grant +// license scanner on each unique image via Docker. +func runGrantOnLockFiles(lockFiles []string, verbose bool, strict bool) error { + if len(lockFiles) == 0 { + return nil + } + + images := collectContainerImagesFromLockFiles(lockFiles) + if len(images) == 0 { + grantLog.Print("No container images found in lock files") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("No container images found in lock files to scan with grant")) + } + return nil + } + + policyFile, err := grantPolicyFile() + if err != nil { + return err + } + + if len(images) == 1 { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running grant license scanner on 1 container image")) + } else { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage( + fmt.Sprintf("Running grant license scanner on %d container images", len(images)))) + } + + totalFindings := 0 + var scanErrors []string + + for _, img := range images { + imageRef := img.PinnedImage + if imageRef == "" { + imageRef = img.Image + } + displayName := img.Image + if displayName == "" { + displayName = imageRef + } + + output, err := grantRunOnImage(imageRef, policyFile, verbose) + if err != nil { + grantLog.Printf("Grant scan failed for %s: %v", displayName, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", displayName, err)) + continue + } + + count, err := grantDisplayFindings(displayName, output) + if err != nil { + grantLog.Printf("Grant findings invalid for %s: %v", displayName, err) + scanErrors = append(scanErrors, fmt.Sprintf("%s: %v", displayName, err)) + continue + } + totalFindings += count + } + + if len(scanErrors) > 0 { + errMsg := fmt.Sprintf("grant scan failed for %d image(s): %s", + len(scanErrors), strings.Join(scanErrors, "; ")) + if strict { + return errors.New(errMsg) + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(errMsg)) + } + + if strict && totalFindings > 0 { + return fmt.Errorf("strict mode: grant found %d license policy finding(s) in container images", totalFindings) + } + + return nil +} + +func grantPolicyFile() (string, error) { + repoRoot, err := gitutil.FindGitRoot() + if err != nil { + return "", fmt.Errorf("grant requires a git repository checkout to locate %s: %w", grantPolicyFilename, err) + } + + policyFile := filepath.Join(repoRoot, grantPolicyFilename) + info, err := os.Stat(policyFile) + if err != nil { + return "", fmt.Errorf("grant requires %s at the repository root: %w", grantPolicyFilename, err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("grant policy file is not a regular file: %s", policyFile) + } + + return policyFile, nil +} + +func grantRunOnImage(imageRef, policyFile string, verbose bool) (*grantOutput, error) { + containerPolicyPath := "/tmp/gh-aw-grant-policy.yaml" + + // #nosec G204 -- imageRef and policyFile are derived from compiled lock files and the + // current repository checkout. exec.Command passes arguments directly without a shell. + cmd := exec.Command( + "docker", + "run", + "--rm", + "-v", policyFile+":"+containerPolicyPath+":ro", + GrantImage, + "--config", containerPolicyPath, + "--output", "json", + "check", + imageRef, + ) + + if verbose { + dockerCmd := fmt.Sprintf("docker run --rm -v %s:%s:ro %s --config %s --output json check %s", + policyFile, containerPolicyPath, GrantImage, containerPolicyPath, imageRef) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Run grant directly: "+dockerCmd)) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + + var output grantOutput + if stdout.Len() > 0 && strings.HasPrefix(strings.TrimSpace(stdout.String()), "{") { + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return nil, fmt.Errorf("failed to parse grant JSON output for %s: %w", imageRef, err) + } + } + + if len(output.Run.Targets) == 0 { + if runErr != nil { + stderrStr := strings.TrimSpace(stderr.String()) + if stderrStr != "" { + return nil, fmt.Errorf("grant failed for %s: %s", imageRef, stderrStr) + } + return nil, fmt.Errorf("grant failed for %s: %w", imageRef, runErr) + } + return nil, fmt.Errorf("grant produced no scan targets for %s", imageRef) + } + + for _, target := range output.Run.Targets { + if target.Evaluation.Status == "error" { + targetRef := target.Source.Ref + if targetRef == "" { + targetRef = imageRef + } + return nil, fmt.Errorf("grant reported an evaluation error for %s", targetRef) + } + } + + return &output, nil +} + +func grantDisplayFindings(imageTag string, output *grantOutput) (int, error) { + if output == nil || len(output.Run.Targets) == 0 { + return 0, nil + } + + total := 0 + for _, target := range output.Run.Targets { + for _, pkg := range target.Evaluation.Findings.Packages { + if pkg.Decision != "deny" { + continue + } + + licenses := "no licenses found" + if len(pkg.Licenses) > 0 { + names := make([]string, 0, len(pkg.Licenses)) + for _, license := range pkg.Licenses { + name := license.ID + if name == "" { + name = license.Name + } + if name != "" { + names = append(names, name) + } + } + if len(names) > 0 { + licenses = strings.Join(names, ", ") + } + } + + message := fmt.Sprintf("license policy violation: %s (%s)", grantPackageRef(pkg), licenses) + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: imageTag, + Line: 1, + Column: 1, + }, + Type: "error", + Message: message, + } + fmt.Fprint(os.Stderr, console.FormatError(compilerErr)) + total++ + } + } + + return total, nil +} + +func grantPackageRef(pkg grantPackageFinding) string { + if pkg.Version == "" { + return pkg.Name + } + return pkg.Name + "@" + pkg.Version +} diff --git a/pkg/cli/grant_test.go b/pkg/cli/grant_test.go new file mode 100644 index 00000000000..052a89529c0 --- /dev/null +++ b/pkg/cli/grant_test.go @@ -0,0 +1,75 @@ +//go:build !integration + +package cli + +import ( + "path/filepath" + "testing" +) + +func TestGrantDisplayFindings_NilOutput(t *testing.T) { + count, err := grantDisplayFindings("test-image:latest", nil) + if err != nil { + t.Fatalf("Expected no error for nil output, got: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 findings for nil output, got %d", count) + } +} + +func TestGrantDisplayFindings_WithDeniedPackages(t *testing.T) { + output := &grantOutput{} + output.Run.Targets = []grantTargetResult{ + { + Evaluation: grantTargetEvaluation{ + Status: "noncompliant", + Findings: struct { + Packages []grantPackageFinding `json:"packages"` + }{ + Packages: []grantPackageFinding{ + { + Name: "openssl", + Version: "1.0.0", + Decision: "deny", + Licenses: []grantLicenseDetail{{ID: "GPL-3.0-only"}}, + }, + { + Name: "nolicense", + Decision: "deny", + }, + { + Name: "allowed", + Version: "1.0.0", + Decision: "allow", + }, + }, + }, + }, + }, + } + + count, err := grantDisplayFindings("ubuntu:24.04", output) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if count != 2 { + t.Errorf("Expected 2 denied packages, got %d", count) + } +} + +func TestRunGrantOnLockFiles_NoLockFiles(t *testing.T) { + err := runGrantOnLockFiles([]string{}, false, false) + if err != nil { + t.Errorf("Expected no error for empty lock file list, got: %v", err) + } +} + +func TestGrantPolicyFile(t *testing.T) { + policyFile, err := grantPolicyFile() + if err != nil { + t.Fatalf("Expected grant policy file, got: %v", err) + } + if filepath.Base(policyFile) != grantPolicyFilename { + t.Fatalf("Expected policy file basename %q, got %q", grantPolicyFilename, filepath.Base(policyFile)) + } +} diff --git a/pkg/cli/mcp_server_defaults_test.go b/pkg/cli/mcp_server_defaults_test.go index 3227e2c0f36..9cd4f9f1d16 100644 --- a/pkg/cli/mcp_server_defaults_test.go +++ b/pkg/cli/mcp_server_defaults_test.go @@ -19,6 +19,7 @@ func TestMCPToolElicitationDefaults(t *testing.T) { Zizmor bool `json:"zizmor,omitempty" jsonschema:"Run zizmor security scanner on generated .lock.yml files"` Poutine bool `json:"poutine,omitempty" jsonschema:"Run poutine security scanner on generated .lock.yml files"` Actionlint bool `json:"actionlint,omitempty" jsonschema:"Run actionlint linter on generated .lock.yml files"` + Grant bool `json:"grant,omitempty" jsonschema:"Run grant license scanner on container images referenced in compiled .lock.yml files"` Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` } diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index 473168b7db3..84958fcd2c2 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -76,6 +76,7 @@ type compileArgs struct { Actionlint bool `json:"actionlint,omitempty" jsonschema:"Run actionlint linter on generated .lock.yml files"` RunnerGuard bool `json:"runner-guard,omitempty" jsonschema:"Run runner-guard taint analysis scanner on generated .lock.yml files"` Grype bool `json:"grype,omitempty" jsonschema:"Run grype vulnerability scanner on container images referenced in compiled .lock.yml files"` + Grant bool `json:"grant,omitempty" jsonschema:"Run grant license scanner on container images referenced in compiled .lock.yml files"` Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` MaxTokens int `json:"max_tokens,omitempty" jsonschema:"Deprecated: accepted for backward compatibility but ignored."` } @@ -139,9 +140,9 @@ Returns JSON array with validation results for each workflow: var dockerUnavailableWarning string // Check if any static analysis tools are requested that require Docker images - if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Grype { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard || args.Grype || args.Grant { // Check if Docker images are available; if not, start downloading and return retry message - if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype, args.Grant); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -153,6 +154,7 @@ Returns JSON array with validation results for each workflow: args.Actionlint = false args.RunnerGuard = false args.Grype = false + args.Grant = false } else { // Images are still downloading — ask the caller to retry. // Build per-workflow validation errors instead of throwing an MCP protocol error, @@ -206,6 +208,9 @@ Returns JSON array with validation results for each workflow: if args.Grype { cmdArgs = append(cmdArgs, "--grype") } + if args.Grant { + cmdArgs = append(cmdArgs, "--grant") + } cmdArgs = append(cmdArgs, args.Workflows...) @@ -215,8 +220,8 @@ Returns JSON array with validation results for each workflow: cmdArgs = append(cmdArgs, "--prior-manifest-file", manifestCacheFile) } - mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, grype=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v, grype=%v, grant=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard, args.Grype, args.Grant) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: From a6b7b98b82b316f56187310d16d806773c8c5565 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:17:05 +0000 Subject: [PATCH 2/4] Tidy grant tool comments Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_external_tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go index 2bfbdd8b48d..03924bb14a6 100644 --- a/pkg/cli/compile_external_tools.go +++ b/pkg/cli/compile_external_tools.go @@ -1,7 +1,7 @@ // This file provides external tool runners for workflow compilation. // // This file contains functions that invoke external analysis tools -// (actionlint, zizmor, poutine, runner-guard) on compiled workflow files. +// (actionlint, zizmor, poutine, runner-guard, grype, grant) on compiled workflow files. // // # Organization Rationale // From a9a2053c67e22bbbdd692d2f3583ae4a42fb5807 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:34:28 +0000 Subject: [PATCH 3/4] docs(adr): draft ADR-47516 for Grant container image license scanning Co-Authored-By: Claude Sonnet 4.6 --- .../47516-add-grant-container-license-scan.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/adr/47516-add-grant-container-license-scan.md diff --git a/docs/adr/47516-add-grant-container-license-scan.md b/docs/adr/47516-add-grant-container-license-scan.md new file mode 100644 index 00000000000..f5431f58459 --- /dev/null +++ b/docs/adr/47516-add-grant-container-license-scan.md @@ -0,0 +1,53 @@ +# ADR-47516: Add Grant Container Image License Scanning to Compile Pipeline + +**Date**: 2026-07-23 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +Compiled GitHub Actions workflows reference container images recorded in each `.lock.yml` file under a `gh-aw-manifest` header. The compile pipeline already enforces container image vulnerability policy via `--grype` (Anchore Grype), but has no mechanism to enforce software license compliance on those images. A repository-level license policy file (`.grant.yaml`) exists but is not applied at compile time. Developers and security teams need a way to detect denied-license packages in container images before workflows ship, without requiring a native scanner binary on every machine. + +### Decision + +We will integrate Anchore Grant as an opt-in post-compilation step (`--grant`) that scans container images extracted from `gh-aw-manifest` headers in compiled `.lock.yml` files. Grant will be invoked via Docker (`docker run --rm -v .grant.yaml:/tmp/policy.yaml:ro anchore/grant:latest --config /tmp/policy.yaml --output json check `) so no native install is required. Images are collected from lock files using the same `collectContainerImagesFromLockFiles` helper used by Grype, deduplicated by pinned reference before scanning. Findings with `"decision": "deny"` are surfaced as `console.CompilerError` entries. In strict mode (`--strict`), any denied-license finding or scan error causes a non-zero exit. + +### Alternatives Considered + +#### Alternative 1: Extend Grype to report license findings alongside CVEs + +Grype's primary focus is CVE detection; it does not evaluate license policy. Adding license output to Grype would require maintaining a fork or relying on unstable experimental features. Rejected because it conflates two distinct concerns (security vulnerabilities vs. license compliance) into a single tool, and Grant is purpose-built for license policy enforcement with first-class policy-file support. + +#### Alternative 2: Use FOSSA or Snyk for license scanning + +FOSSA and Snyk offer license scanning as a service with richer policy management and audit trails. Both require network access to a managed service and API credentials, making them unsuitable for offline or air-gapped compile runs. Grant runs fully locally via Docker and reads the existing `.grant.yaml` policy file, requiring no external service or credential management. + +#### Alternative 3: Perform license scanning as a separate CI step outside the compile pipeline + +A dedicated CI job could invoke grant on images listed in manifests after compilation. This keeps the compile pipeline lightweight and lets scanning scale independently. Rejected because coupling the scan to `gh aw compile --grant` gives developers immediate local feedback without requiring CI configuration changes, and aligns with the existing pattern established by `--grype`, `--zizmor`, `--poutine`, and `--runner-guard`. + +### Consequences + +#### Positive +- License compliance is enforced at compile time using the repository's own `.grant.yaml` policy, with no additional configuration needed. +- No native Grant installation required; Docker is the only prerequisite (already required by `--runner-guard`, `--poutine`, and `--grype`). +- Grant findings are surfaced through the existing `console.CompilerError` format, giving a unified developer experience alongside other scanner output. +- Strict mode integration (`--strict`) allows CI pipelines to gate on license violations without additional scripting. +- Follows the established opt-in scanner plugin pattern; existing compile invocations are unaffected. + +#### Negative +- Docker must be running; users without a running Docker daemon silently skip grant scanning rather than receiving a hard error, which may create a false sense of license compliance. +- `anchore/grant:latest` is pinned to a mutable tag. Upstream releases may produce different results or break scanning non-deterministically across environments. +- Requires `.grant.yaml` to exist at the repository root; if absent, the scan fails rather than skipping gracefully, blocking the compile run. +- Each unique container image requires a separate `docker run` invocation; the first use per machine triggers a Docker image pull, adding significant latency. + +#### Neutral +- The `--grant` flag follows the same opt-in model as `--zizmor`, `--poutine`, `--actionlint`, `--runner-guard`, and `--grype`. +- The MCP tool interface (`mcp_tools_readonly.go`) exposes `grant` as a JSON schema field, making it available to AI-assisted compile invocations. +- Grant's policy evaluation is determined by `.grant.yaml`; changes to that file change scan outcomes without code changes to this feature. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From b00c91cd0644f1f32b03b18397a9d64b0e2aaf26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:58:54 +0000 Subject: [PATCH 4/4] Fix grant compile flow and strict JSON failure handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_pipeline.go | 66 +++++++++++++++++++++++++++---------- pkg/cli/grant.go | 6 +++- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 40cc9707b28..816f0defe29 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -65,6 +65,7 @@ func compileSpecificFiles( var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForGrype []string // lock files for grype container image vulnerability scanning var lockFilesForGrant []string // lock files for grant container image license scanning + var strictGrantErr error // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -225,16 +226,27 @@ func compileSpecificFiles( return workflowDataList, err } } + } - // Run grant license scanner on container images referenced in the compiled lock files. - if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { - if err := ctx.Err(); err != nil { - return workflowDataList, err - } - if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { - if config.Strict { - return workflowDataList, err - } + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, "grant", 1, []string{err.Error()}) + *validationResults = append(*validationResults, ValidationResult{ + Workflow: "grant", + Valid: false, + Errors: []CompileValidationError{{ + Type: "grant_error", + Message: err.Error(), + }}, + }) + strictGrantErr = err } } } @@ -262,6 +274,9 @@ func compileSpecificFiles( // Don't return the detailed error message here since it's already printed in the summary // Returning a simple error prevents duplication in the output if errorCount > 0 { + if strictGrantErr != nil { + return workflowDataList, strictGrantErr + } return workflowDataList, errors.New("compilation failed") } @@ -336,6 +351,7 @@ func compileAllFilesInDirectory( var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) var lockFilesForGrype []string // lock files for grype container image vulnerability scanning var lockFilesForGrant []string // lock files for grant container image license scanning + var strictGrantErr error for _, file := range mdFiles { // Respect context cancellation between files (e.g. Ctrl+C) @@ -461,16 +477,27 @@ func compileAllFilesInDirectory( return workflowDataList, err } } + } - // Run grant license scanner on container images referenced in the compiled lock files. - if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { - if err := ctx.Err(); err != nil { - return workflowDataList, err - } - if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { - if config.Strict { - return workflowDataList, err - } + // Run grant license scanner on container images referenced in the compiled lock files. + if config.Grant && !config.NoEmit && len(lockFilesForGrant) > 0 { + if err := ctx.Err(); err != nil { + return workflowDataList, err + } + if err := RunGrantOnLockFiles(lockFilesForGrant, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + errorCount++ + stats.Errors++ + trackWorkflowFailure(stats, "grant", 1, []string{err.Error()}) + *validationResults = append(*validationResults, ValidationResult{ + Workflow: "grant", + Valid: false, + Errors: []CompileValidationError{{ + Type: "grant_error", + Message: err.Error(), + }}, + }) + strictGrantErr = err } } } @@ -513,6 +540,9 @@ func compileAllFilesInDirectory( // Return error if any compilations failed if errorCount > 0 { + if strictGrantErr != nil { + return workflowDataList, strictGrantErr + } return workflowDataList, errors.New("compilation failed") } diff --git a/pkg/cli/grant.go b/pkg/cli/grant.go index d3b6b82704a..b28c28b02cb 100644 --- a/pkg/cli/grant.go +++ b/pkg/cli/grant.go @@ -135,7 +135,11 @@ func grantPolicyFile() (string, error) { policyFile := filepath.Join(repoRoot, grantPolicyFilename) info, err := os.Stat(policyFile) if err != nil { - return "", fmt.Errorf("grant requires %s at the repository root: %w", grantPolicyFilename, err) + return "", fmt.Errorf( + "grant requires %s at the repository root (create it or run compile without --grant): %w", + grantPolicyFilename, + err, + ) } if !info.Mode().IsRegular() { return "", fmt.Errorf("grant policy file is not a regular file: %s", policyFile)