Repository: github/gh-aw (2026-04-01T11:58:19Z)

Issue:
[plan] Parameterize `engine.version` to accept GitHub Actions expressions (injection-safe)
## Objective

Allow the `engine.version` frontmatter field to accept GitHub Actions expression strings (e.g. `${{ inputs.engine-version }}`), enabling reusable `workflow_call` workflows to pin or override the engine version at call time.

## Context

Tracked in issue #23724. The field is currently interpolated directly into shell arguments, so the implementation must include injection-safe handling. This is different from `engine.id` (which requires an architectural change) — `engine.version` is a version constraint string that can safely be passed through as a runtime value.

## Security Concern

`engine.version` is interpolated into shell commands (e.g., `npm install `@github/copilot`@<version>`). When accepting expressions, the compiled YAML must ensure:
- The expression is embedded as a GitHub Actions expression (evaluated before shell execution), **not** concatenated into a raw shell string.
- The shell step must use the expression value via an env var, not via shell substitution, to avoid injection.

## Current Behavior

```yaml
engine:
  id: copilot
  version: "1.2.3"  # Only literal strings work
```

## Expected Behavior

```yaml
engine:
  id: copilot
  version: ${{ inputs.engine-version }}  # ✅ Expression string
```

## Approach

1. **Identify compilation sites** for `engine.version`:
   - Search `pkg/workflow/copilot_engine_installation.go`, `claude_engine.go`, `codex_engine.go`, `gemini_engine.go` for where `config.Version` is used.
   - Each engine likely emits a different step that uses the version string.

2. **Update `EngineConfig`** in `pkg/workflow/engine.go`:
   - The `Version` field is already a `string`; no type change needed.
   - Add expression detection: if `version` value matches `${{ ... }}`, mark it as a runtime expression.

3. **Update frontmatter extraction** in `pkg/workflow/engine.go` (`ExtractEngineConfig`):
   - Accept `${{ ... }}` strings as valid `version` values (currently only plain strings are accepted).

4. **Update each engine's compilation** to emit the version expression safely:
   - Instead of `npm install package@${version}`, use an env var: `env: { ENGINE_VERSION: "${{ inputs.engine-version }}" }` and reference `$ENGINE_VERSION` in the shell command.
   - This prevents shell injection regardless of expression content.

5. **Update JSON schema** to accept expression strings for `engine.version`.

6. **Add tests** covering expression versions for each engine.

## Files to Modify

- `pkg/workflow/engine.go` — expression detection in `ExtractEngineConfig`
- `pkg/workflow/copilot_engine_installation.go` — safe env var injection
- `pkg/workflow/claude_engine.go`, `codex_engine.go`, `gemini_engine.go` — same
- `pkg/parser/schemas/main_workflow_schema.json` — relax version constraint
- Tests in `pkg/workflow/`

## Acceptance Criteria

- [ ] `engine.version: "1.2.3"` still works (backward compatible)
- [ ] `engine.version: ${{ inputs.engine-version }}` compiles and the expression appears in the compiled `.lock.yml` via env var (not raw shell interpolation)
- [ ] No shell injection is possible via the expression value
- [ ] Tests pass: `make agent-finish`
Related to #23724




> Generated by [Plan Command](https://github.com/github/gh-aw/actions/runs/23833734680/agentic_workflow) for issue #23724 · [◷](https://github.com/search?q=repo%3Agithub%2Fgh-aw+is%3Aissue+%22gh-aw-workflow-call-id%3A+github%2Fgh-aw%2Fplan%22&type=issues)
> - [x] expires <!-- gh-aw-expires: 2026-04-03T05:44:52.619Z --> on Apr 3, 2026, 5:44 AM UTC

<!-- gh-aw-agentic-workflow: Plan Command, engine: copilot, model: auto, id: 23833734680, workflow_id: plan, run: https://github.com/github/gh-aw/actions/runs/23833734680 -->

<!-- gh-aw-workflow-id: plan -->
<!-- gh-aw-workflow-call-id: github/gh-aw/plan -->


Discussion:
🍪 **Issue Monster has assigned this to Copilot!**

I've identified this issue as a good candidate for automated resolution and assigned it to the Copilot coding agent.

The Copilot coding agent will analyze the issue and create a pull request with the fix.

Om nom nom! 🍪

> 🍪 *Om nom nom by [Issue Monster](https://github.com/github/gh-aw/actions/runs/23847212242)* · [◷](https://github.com/search?q=repo%3Agithub%2Fgh-aw+%22gh-aw-workflow-call-id%3A+github%2Fgh-aw%2Fissue-monster%22&type=issues)

<!-- gh-aw-agentic-workflow: Issue Monster, engine: copilot, model: gpt-5.1-codex-mini, id: 23847212242, workflow_id: issue-monster, run: https://github.com/github/gh-aw/actions/runs/23847212242 -->
<!-- gh-aw-workflow-call-id: github/gh-aw/issue-monster -->



Patch:
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 628d14a6cb7..78318fd4d70 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -9026,8 +9026,8 @@
             },
             "version": {
               "type": ["string", "number"],
-              "description": "Optional version of the AI engine action (e.g., 'beta', 'stable', 20). Has sensible defaults and can typically be omitted. Numeric values are automatically converted to strings at runtime.",
-              "examples": ["beta", "stable", 20, 3.11]
+              "description": "Optional version of the AI engine action (e.g., 'beta', 'stable', 20). Has sensible defaults and can typically be omitted. Numeric values are automatically converted to strings at runtime. GitHub Actions expressions (e.g., '${{ inputs.engine-version }}') are accepted and compiled with injection-safe env var handling.",
+              "examples": ["beta", "stable", 20, 3.11, "${{ inputs.engine-version }}"]
             },
             "model": {
               "type": "string",
diff --git a/pkg/workflow/copilot_installer.go b/pkg/workflow/copilot_installer.go
index 70ebb944412..e390f00860d 100644
--- a/pkg/workflow/copilot_installer.go
+++ b/pkg/workflow/copilot_installer.go
@@ -24,6 +24,19 @@ func GenerateCopilotInstallerSteps(version, stepName string) []GitHubActionStep
 	// and does not use gh CLI, so GH_HOST does not affect the download. No step-level
 	// GH_HOST override is needed here; the correct host is already set in GITHUB_ENV
 	// by configure_gh_for_ghe.sh (or by the Derive GH_HOST step when DIFC proxy is active).
+	if ExpressionPattern.MatchString(version) {
+		// Version is a GitHub Actions expression (e.g. ${{ inputs.engine-version }}).
+		// Pass it via an env var instead of direct shell interpolation to prevent injection.
+		copilotInstallerLog.Printf("Version contains GitHub Actions expression, using env var for injection safety: %s", version)
+		stepLines := []string{
+			"      - name: " + stepName,
+			`        run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh "${ENGINE_VERSION}"`,
+			"        env:",
+			"          ENGINE_VERSION: " + version,
+		}
+		return []GitHubActionStep{GitHubActionStep(stepLines)}
+	}
+
 	stepLines := []string{
 		"      - name: " + stepName,
 		"        run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh " + version,
diff --git a/pkg/workflow/nodejs.go b/pkg/workflow/nodejs.go
index 1dae06cff6e..1e656510123 100644
--- a/pkg/workflow/nodejs.go
+++ b/pkg/workflow/nodejs.go
@@ -52,11 +52,29 @@ func GenerateNpmInstallStepsWithScope(packageName, version, stepName, cacheKeyPr
 	if isGlobal {
 		globalFlag = "-g "
 	}
-	installCmd := fmt.Sprintf("npm install %s%s@%s", globalFlag, packageName, version)
-	steps = append(steps, GitHubActionStep{
-		"      - name: " + stepName,
-		"        run: " + installCmd,
-	})
+
+	var installStep GitHubActionStep
+	if ExpressionPattern.MatchString(version) {
+		// Version is a GitHub Actions expression (e.g. ${{ inputs.engine-version }}).
+		// Pass it via an env var instead of direct shell interpolation to prevent injection:
+		// if the expression evaluates to a malicious string, it would otherwise be
+		// substituted verbatim into the shell command before the shell parses it.
+		nodejsLog.Printf("Version contains GitHub Actions expression, using env var for injection safety: %s", version)
+		installCmd := fmt.Sprintf(`npm install %s%s@"${ENGINE_VERSION}"`, globalFlag, packageName)
+		installStep = GitHubActionStep{
+			"      - name: " + stepName,
+			"        run: " + installCmd,
+			"        env:",
+			"          ENGINE_VERSION: " + version,
+		}
+	} else {
+		installCmd := fmt.Sprintf("npm install %s%s@%s", globalFlag, packageName, version)
+		installStep = GitHubActionStep{
+			"      - name: " + stepName,
+			"        run: " + installCmd,
+		}
+	}
+	steps = append(steps, installStep)
 
 	return steps
 }


Test patch:
diff --git a/.github/aw/test-expression.md b/.github/aw/test-expression.md
new file mode 100644
index 00000000000..9db200a76f5
--- /dev/null
+++ b/.github/aw/test-expression.md
@@ -0,0 +1,11 @@
+---
+on:
+  workflow_call:
+    inputs:
+      engine-version:
+        type: string
+engine:
+  id: copilot
+  version: ${{ inputs.engine-version }}
+---
+Fix the bug
diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go
index 6a7dda7cfc7..0be8ea6a4e7 100644
--- a/pkg/workflow/claude_engine_test.go
+++ b/pkg/workflow/claude_engine_test.go
@@ -513,3 +513,40 @@ func TestClaudeEngineEnvOverridesTokenExpression(t *testing.T) {
 		}
 	})
 }
+
+func TestClaudeEngineWithExpressionVersion(t *testing.T) {
+	engine := NewClaudeEngine()
+
+	expressionVersion := "${{ inputs.engine-version }}"
+	workflowData := &WorkflowData{
+		Name: "test-workflow",
+		EngineConfig: &EngineConfig{
+			ID:      "claude",
+			Version: expressionVersion,
+		},
+	}
+
+	// Expression version must use env var injection in the install step
+	installSteps := engine.GetInstallationSteps(workflowData)
+	// Expect: Node.js setup step + install step
+	if len(installSteps) != 2 {
+		t.Fatalf("Expected 2 installation steps, got %d", len(installSteps))
+	}
+
+	installStep := strings.Join([]string(installSteps[1]), "\n")
+
+	// Should use ENGINE_VERSION env var
+	if !strings.Contains(installStep, "ENGINE_VERSION: "+expressionVersion) {
+		t.Errorf("Expected ENGINE_VERSION env var in install step, got:\n%s", installStep)
+	}
+
+	// Should reference env var in command
+	if !strings.Contains(installStep, `"${ENGINE_VERSION}"`) {
+		t.Errorf(`Expected "$ENGINE_VERSION" in npm install command, got:\n%s`, installStep)
+	}
+
+	// Should NOT embed expression directly in shell command
+	if strings.Contains(installStep, "@anthropic-ai/claude-code@"+expressionVersion) {
+		t.Errorf("Expression should NOT be embedded directly in npm install command, got:\n%s", installStep)
+	}
+}
diff --git a/pkg/workflow/codex_engine_test.go b/pkg/workflow/codex_engine_test.go
index cc11c26c449..f1bd3ba8401 100644
--- a/pkg/workflow/codex_engine_test.go
+++ b/pkg/workflow/codex_engine_test.go
@@ -771,3 +771,47 @@ func TestCodexEngineWebSearch(t *testing.T) {
 		}
 	})
 }
+
+func TestCodexEngineWithExpressionVersion(t *testing.T) {
+	engine := NewCodexEngine()
+
+	expressionVersion := "${{ inputs.engine-version }}"
+	workflowData := &WorkflowData{
+		Name: "test-workflow",
+		EngineConfig: &EngineConfig{
+			ID:      "codex",
+			Version: expressionVersion,
+		},
+	}
+
+	installSteps := engine.GetInstallationSteps(workflowData)
+
+	// Find the npm install step
+	var installStep string
+	for _, step := range installSteps {
+		stepContent := strings.Join(step, "\n")
+		if strings.Contains(stepContent, "npm install") {
+			installStep = stepContent
+			break
+		}
+	}
+
+	if installStep == "" {
+		t.Fatal("Could not find npm install step")
+	}
+
+	// Should use ENGINE_VERSION env var for injection safety
+	if !strings.Contains(installStep, "ENGINE_VERSION: "+expressionVersion) {
+		t.Errorf("Expected ENGINE_VERSION env var in install step, got:\n%s", installStep)
+	}
+
+	// Should reference env var in command
+	if !strings.Contains(installStep, `"${ENGINE_VERSION}"`) {
+		t.Errorf(`Expected "$ENGINE_VERSION" in npm install command, got:\n%s`, installStep)
+	}
+
+	// Should NOT embed expression directly in npm install command
+	if strings.Contains(installStep, "@openai/codex@"+expressionVersion) {
+		t.Errorf("Expression should NOT be embedded directly in npm install command, got:\n%s", installStep)
+	}
+}
diff --git a/pkg/workflow/copilot_installer_test.go b/pkg/workflow/copilot_installer_test.go
index b6e0ccf39e6..3454174cfa7 100644
--- a/pkg/workflow/copilot_installer_test.go
+++ b/pkg/workflow/copilot_installer_test.go
@@ -150,3 +150,99 @@ func TestCopilotInstallerCustomVersion(t *testing.T) {
 		t.Errorf("Install step should NOT hardcode GH_HOST: github.com (breaks GHEC), got:\n%s", installStep)
 	}
 }
+
+func TestGenerateCopilotInstallerSteps_ExpressionVersion(t *testing.T) {
+	tests := []struct {
+		name    string
+		version string
+		envVar  string
+	}{
+		{
+			name:    "workflow_call input expression",
+			version: "${{ inputs.engine-version }}",
+			envVar:  "ENGINE_VERSION: ${{ inputs.engine-version }}",
+		},
+		{
+			name:    "github event input expression",
+			version: "${{ github.event.inputs.engine-version }}",
+			envVar:  "ENGINE_VERSION: ${{ github.event.inputs.engine-version }}",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			steps := GenerateCopilotInstallerSteps(tt.version, "Install GitHub Copilot CLI")
+
+			if len(steps) != 1 {
+				t.Errorf("Expected 1 step, got %d", len(steps))
+				return
+			}
+
+			stepContent := strings.Join(steps[0], "\n")
+
+			// Should use env var section
+			if !strings.Contains(stepContent, "env:") {
+				t.Errorf("Expected step to contain 'env:' section for expression version, got:\n%s", stepContent)
+			}
+
+			// Should define ENGINE_VERSION env var with the expression
+			if !strings.Contains(stepContent, tt.envVar) {
+				t.Errorf("Expected step to contain %q, got:\n%s", tt.envVar, stepContent)
+			}
+
+			// Should reference ENGINE_VERSION in the run command
+			if !strings.Contains(stepContent, `"${ENGINE_VERSION}"`) {
+				t.Errorf(`Expected step to use "$ENGINE_VERSION" in run command, got:\n%s`, stepContent)
+			}
+
+			// Should NOT embed the expression directly in the shell command
+			if strings.Contains(stepContent, "install_copilot_cli.sh "+tt.version) {
+				t.Errorf("Expression version should NOT be embedded directly in shell command, got:\n%s", stepContent)
+			}
+		})
+	}
+}
+
+func TestCopilotInstallerExpressionVersion_ViaEngineConfig(t *testing.T) {
+	// Test that expression version from engine config uses env var injection
+	engine := NewCopilotEngine()
+
+	expressionVersion := "${{ inputs.engine-version }}"
+	workflowData := &WorkflowData{
+		Name: "test-workflow",
+		EngineConfig: &EngineConfig{
+			Version: expressionVersion,
+		},
+	}
+
+	steps := engine.GetInstallationSteps(workflowData)
+
+	// Find the install step
+	var installStep string
+	for _, step := range steps {
+		stepContent := strings.Join(step, "\n")
+		if strings.Contains(stepContent, "install_copilot_cli.sh") {
+			installStep = stepContent
+			break
+		}
+	}
+
+	if installStep == "" {
+		t.Fatal("Could not find install step with install_copilot_cli.sh")
+	}
+
+	// Should use env var for injection safety
+	if !strings.Contains(installStep, "ENGINE_VERSION: ${{ inputs.engine-version }}") {
+		t.Errorf("Expected ENGINE_VERSION env var in install step, got:\n%s", installStep)
+	}
+
+	// Should reference env var in run command
+	if !strings.Contains(installStep, `"${ENGINE_VERSION}"`) {
+		t.Errorf(`Expected "$ENGINE_VERSION" in run command, got:\n%s`, installStep)
+	}
+
+	// Should NOT embed expression directly in shell command
+	if strings.Contains(installStep, "install_copilot_cli.sh "+expressionVersion) {
+		t.Errorf("Expression should NOT be embedded directly in shell command, got:\n%s", installStep)
+	}
+}
diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go
index 472c0dc928c..c9bb9df7cf5 100644
--- a/pkg/workflow/engine_config_test.go
+++ b/pkg/workflow/engine_config_test.go
@@ -60,6 +60,17 @@ func TestExtractEngineConfig(t *testing.T) {
 			expectedEngineSetting: "claude",
 			expectedConfig:        &EngineConfig{ID: "claude", Version: "beta"},
 		},
+		{
+			name: "object format - with expression version",
+			frontmatter: map[string]any{
+				"engine": map[string]any{
+					"id":      "copilot",
+					"version": "${{ inputs.engine-version }}",
+				},
+			},
+			expectedEngineSetting: "copilot",
+			expectedConfig:        &EngineConfig{ID: "copilot", Version: "${{ inputs.engine-version }}"},
+		},
 		{
 			name: "object format - with integer version",
 			frontmatter: map[string]any{
diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go
index 16c64ce3a99..4b023b714a8 100644
--- a/pkg/workflow/gemini_engine_test.go
+++ b/pkg/workflow/gemini_engine_test.go
@@ -526,3 +526,47 @@ func TestGenerateGeminiSettingsStep(t *testing.T) {
 		assert.Contains(t, content, "GH_AW_GEMINI_BASE_CONFIG: '", "JSON env var value must be single-quoted for valid YAML")
 	})
 }
+
+func TestGeminiEngineWithExpressionVersion(t *testing.T) {
+	engine := NewGeminiEngine()
+
+	expressionVersion := "${{ inputs.engine-version }}"
+	workflowData := &WorkflowData{
+		Name: "test-workflow",
+		EngineConfig: &EngineConfig{
+			ID:      "gemini",
+			Version: expressionVersion,
+		},
+	}
+
+	installSteps := engine.GetInstallationSteps(workflowData)
+
+	// Find the npm install step
+	var installStep string
+	for _, step := range installSteps {
+		stepContent := strings.Join([]string(step), "\n")
+		if strings.Contains(stepContent, "npm install") {
+			installStep = stepContent
+			break
+		}
+	}
+
+	if installStep == "" {
+		t.Fatal("Could not find npm install step")
+	}
+
+	// Should use ENGINE_VERSION env var for injection safety
+	if !strings.Contains(installStep, "ENGINE_VERSION: "+expressionVersion) {
+		t.Errorf("Expected ENGINE_VERSION env var in install step, got:\n%s", installStep)
+	}
+
+	// Should reference env var in command
+	if !strings.Contains(installStep, `"${ENGINE_VERSION}"`) {
+		t.Errorf(`Expected "$ENGINE_VERSION" in npm install command, got:\n%s`, installStep)
+	}
+
+	// Should NOT embed expression directly in npm install command
+	if strings.Contains(installStep, "@google/gemini-cli@"+expressionVersion) {
+		t.Errorf("Expression should NOT be embedded directly in npm install command, got:\n%s", installStep)
+	}
+}