diff --git a/.github/workflows/smoke-crush.lock.yml b/.github/workflows/smoke-crush.lock.yml index 65f8effa241..7ef0d23dbaf 100644 --- a/.github/workflows/smoke-crush.lock.yml +++ b/.github/workflows/smoke-crush.lock.yml @@ -444,7 +444,10 @@ jobs: - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.29 - name: Install Crush CLI - run: npm install --ignore-scripts -g @charmland/crush@0.59.0 + run: | + export NPM_CONFIG_PREFIX="${RUNNER_TEMP}/npm-global" + mkdir -p "${RUNNER_TEMP}/npm-global/bin" + npm install --ignore-scripts -g @charmland/crush@0.59.0 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 @@ -915,7 +918,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["*.githubusercontent.com","api.anthropic.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","charm.land","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","docs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","lfs.github.com","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="${RUNNER_TEMP}/npm-global/bin:$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: http://host.docker.internal:10000 @@ -1346,7 +1349,10 @@ jobs: - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.29 - name: Install Crush CLI - run: npm install --ignore-scripts -g @charmland/crush@0.59.0 + run: | + export NPM_CONFIG_PREFIX="${RUNNER_TEMP}/npm-global" + mkdir -p "${RUNNER_TEMP}/npm-global/bin" + npm install --ignore-scripts -g @charmland/crush@0.59.0 - name: Write Crush Config if: always() && steps.detection_guard.outputs.run_detection == 'true' continue-on-error: true @@ -1372,7 +1378,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["api.anthropic.com","charm.land","github.com","host.docker.internal","raw.githubusercontent.com","registry.npmjs.org"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/npm-global/bin:$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: http://host.docker.internal:10000 diff --git a/pkg/workflow/crush_engine.go b/pkg/workflow/crush_engine.go index c72e94cb762..dbf043757d7 100644 --- a/pkg/workflow/crush_engine.go +++ b/pkg/workflow/crush_engine.go @@ -10,6 +10,13 @@ import ( var crushLog = logger.New("workflow:crush_engine") +// crushNpmGlobalPrefix is the writable npm global prefix used when installing +// Crush CLI. GitHub-hosted runners mount the Node.js toolcache at +// /opt/hostedtoolcache with EROFS (read-only); npm install -g without a custom +// prefix fails. Pointing npm to $RUNNER_TEMP keeps the install writable while +// making the resulting binary findable via GetCrushNpmBinPathSetup. +const crushNpmGlobalPrefix = "${RUNNER_TEMP}/npm-global" + // CrushEngine represents the Crush CLI agentic engine. // Crush is a provider-agnostic, open-source AI coding agent with broader BYOK // (Bring Your Own Key) support, but gh-aw currently supports a subset of @@ -59,16 +66,58 @@ func (e *CrushEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA return []GitHubActionStep{} } - npmSteps := BuildStandardNpmEngineInstallSteps( - "@charmland/crush", - string(constants.DefaultCrushVersion), - "Install Crush CLI", - "crush", - workflowData, - ) + // Determine version to install + version := string(constants.DefaultCrushVersion) + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { + version = workflowData.EngineConfig.Version + } + + // Use Node.js setup + custom install step that redirects npm's global prefix + // to a writable directory. GitHub-hosted runners mount the Node.js toolcache + // at /opt/hostedtoolcache with EROFS (read-only); npm install -g without a + // custom prefix fails with EROFS. + npmSteps := []GitHubActionStep{ + GenerateNodeJsSetupStep(), + e.buildCrushInstallStep(version), + } return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) } +// buildCrushInstallStep generates an npm install step for the Crush CLI that +// redirects npm's global prefix to a writable directory under $RUNNER_TEMP. +// GitHub-hosted runners mount /opt/hostedtoolcache with EROFS (read-only +// filesystem) so a plain `npm install -g` fails. By setting NPM_CONFIG_PREFIX +// we avoid the read-only toolcache while keeping the binary accessible via +// GetCrushNpmBinPathSetup at execution time. +func (e *CrushEngine) buildCrushInstallStep(version string) GitHubActionStep { + baseCmd := "export NPM_CONFIG_PREFIX=\"" + crushNpmGlobalPrefix + "\"\nmkdir -p \"" + crushNpmGlobalPrefix + "/bin\"\n" + + var command string + var env map[string]string + + if ExpressionPattern.MatchString(version) { + // Version is a GitHub Actions expression – pass through an env var to + // prevent shell injection if the expression evaluates to an attacker- + // controlled string. + command = baseCmd + `npm install --ignore-scripts -g @charmland/crush@"${ENGINE_VERSION}"` + env = map[string]string{"ENGINE_VERSION": version} + } else { + command = baseCmd + "npm install --ignore-scripts -g @charmland/crush@" + version + } + + stepLines := []string{" - name: Install Crush CLI"} + stepLines = FormatStepWithCommandAndEnv(stepLines, command, env) + return GitHubActionStep(stepLines) +} + +// GetCrushNpmBinPathSetup returns a shell command that prepends Crush's writable +// npm global bin directory to PATH, followed by the standard hostedtoolcache bin +// directories. Call this before executing the crush binary so it is found +// regardless of the runner's Node.js toolcache layout. +func GetCrushNpmBinPathSetup() string { + return `export PATH="` + crushNpmGlobalPrefix + `/bin:$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\n' ':')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true` +} + // GetSecretValidationStep returns the secret validation step for the Crush engine. // Returns an empty step if copilot-requests feature is enabled (uses GitHub Actions token). func (e *CrushEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { @@ -153,7 +202,7 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri panic(fmt.Sprintf("BUG: invalid model %q reached domain computation (should have been caught by validation): %v", model, err)) } - npmPathSetup := GetNpmBinPathSetup() + npmPathSetup := GetCrushNpmBinPathSetup() crushCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, crushCommand) if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { crushCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, crushCommandWithPath) @@ -168,7 +217,9 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri AllowedDomains: allowedDomains, }) } else { - command = fmt.Sprintf("set -o pipefail\n%s 2>&1 | tee -a %s", crushCommand, logFile) + // Add PATH setup so crush is found from its writable npm global prefix. + pathSetup := GetCrushNpmBinPathSetup() + command = fmt.Sprintf("set -o pipefail\n%s\n%s 2>&1 | tee -a %s", pathSetup, crushCommand, logFile) } env := map[string]string{ diff --git a/pkg/workflow/crush_engine_test.go b/pkg/workflow/crush_engine_test.go index ef931cf2ca4..a115eb0d154 100644 --- a/pkg/workflow/crush_engine_test.go +++ b/pkg/workflow/crush_engine_test.go @@ -165,6 +165,43 @@ func TestCrushEngineInstallation(t *testing.T) { assert.GreaterOrEqual(t, len(steps), 2, "Should have at least 2 installation steps") }) + t.Run("install step uses writable npm prefix to avoid EROFS", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetInstallationSteps(workflowData) + require.GreaterOrEqual(t, len(steps), 2, "Should have at least 2 installation steps") + + // Find the Install Crush CLI step (last step, after Node.js setup) + installStep := steps[len(steps)-1] + installContent := strings.Join(installStep, "\n") + + assert.Contains(t, installContent, "Install Crush CLI", "Should be the install step") + assert.Contains(t, installContent, "NPM_CONFIG_PREFIX", "Should set NPM_CONFIG_PREFIX to redirect from read-only toolcache") + assert.Contains(t, installContent, "${RUNNER_TEMP}/npm-global", "Should redirect npm prefix to writable RUNNER_TEMP directory") + assert.Contains(t, installContent, "--ignore-scripts", "Should use --ignore-scripts for supply-chain safety") + assert.Contains(t, installContent, "@charmland/crush", "Should install the crush package") + }) + + t.Run("install step with expression version uses ENGINE_VERSION env var", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Version: "${{ inputs.crush-version }}", + }, + } + + steps := engine.GetInstallationSteps(workflowData) + require.GreaterOrEqual(t, len(steps), 2, "Should have at least 2 installation steps") + + installStep := steps[len(steps)-1] + installContent := strings.Join(installStep, "\n") + + assert.Contains(t, installContent, "ENGINE_VERSION:", "Should pass expression version via env var for injection safety") + assert.Contains(t, installContent, `"${ENGINE_VERSION}"`, "Should reference ENGINE_VERSION in install command") + }) + t.Run("custom command skips installation", func(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", @@ -345,6 +382,18 @@ func TestCrushEngineExecution(t *testing.T) { assert.Contains(t, stepContent, "CUSTOM_VAR: custom-value", "engine.env non-secret vars should be included") }) + t.Run("non-firewall execution includes npm-global/bin in PATH", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "${RUNNER_TEMP}/npm-global/bin", "Non-firewall path should include writable npm global bin in PATH") + }) + t.Run("config step is first", func(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", @@ -390,6 +439,7 @@ func TestCrushEngineFirewallIntegration(t *testing.T) { assert.Contains(t, stepContent, "allowDomains", "Should include allowDomains in config JSON") assert.Contains(t, stepContent, `"enabled":true`, "Should include apiProxy enabled in config JSON") assert.Contains(t, stepContent, "GITHUB_COPILOT_BASE_URL: http://host.docker.internal:10002", "Should route copilot/* fallback through Copilot LLM gateway URL") + assert.Contains(t, stepContent, "${RUNNER_TEMP}/npm-global/bin", "Firewall path should include writable npm global bin in PATH") }) t.Run("firewall enabled adds mounted MCP CLI path setup", func(t *testing.T) {