Skip to content

Threat-detection job silently no-ops on arc-dind runners: Copilot binary not staged into the chroot (spawn /usr/local/bin/copilot ENOENT) #44249

Description

@github-antoine-brechon

Summary

With runner.topology: arc-dind (ARC + Docker-in-Docker sidecar), the agent job works correctly (after #44145), but the threat-detection job silently no-ops: its Copilot CLI execution fails with spawn /usr/local/bin/copilot ENOENT inside the AWF chroot, the failure is swallowed by the detection step's continue-on-error: true, and the job reports success while the detection model never runs.

Net effect: threat detection provides no protection on arc-dind runners while appearing green. This is a security-relevant silent failure.

Root cause: the detection job constructs a fresh minimal WorkflowData that does not carry RunnerConfig, so every isArcDindTopology()-gated daemon-visibility behavior (Copilot staging step + daemon-visible spawn path) is skipped for the detection job only.

Environment

  • gh-aw v0.82.4 (AWF v0.27.27, mcpg v0.4.0)
  • Engine: GitHub Copilot CLI (engine: copilot)
  • Runner: actions-runner-controller gha-runner-scale-set with a privileged dind sidecar (split filesystem), runner: topology: arc-dind
  • safe-outputs.threat-detection enabled, inline detection engine (default; gh-aw-detection feature flag off), squid network-isolation

Symptom

The detection job is green, but its log shows the engine never started:

[copilot-harness] pre-flight: command not found: /usr/local/bin/copilot (F_OK check failed — binary does not exist at this path)
[copilot-harness] attempt 1: failed to start process '/usr/local/bin/copilot': spawn /usr/local/bin/copilot ENOENT
[WARN] Command completed with exit code: 1
##[error]Process completed with exit code 1.
📋 continue-on-error: true
##[error]❌ Failed to parse detection result: No THREAT_DETECTION_RESULT found in detection log.

The execution step is marked continue-on-error: true (intended to tolerate transient infra/API failures), so the ENOENT never fails the job and detection silently yields no result.

Root cause (source analysis — pkg/workflow)

The Copilot binary is handled in two places, both gated on isArcDindTopology(workflowData):

  1. Daemon-visible staging stepnodejs.go, BuildNpmEngineInstallStepsWithAWF:
    if isFirewallEnabled(workflowData) && isArcDindTopology(workflowData) {
        // emits "Copy Copilot CLI to daemon-visible path":
        //   cp /usr/local/bin/copilot "${RUNNER_TEMP}/gh-aw/bin/copilot"
    }
  2. Spawn pathcopilot_engine_execution.go, resolveCopilotCommand:
    if isArcDindTopology(workflowData) {
        return constants.GhAwRootDirShell + "/bin/copilot", ""   // daemon-visible copy
    }
    return constants.CopilotBinaryPath, ""                        // /usr/local/bin/copilot

isArcDindTopologygetRunnerTopology(workflowData)workflowData.RunnerConfig.Topology, which returns "" when RunnerConfig == nil (awf_config.go).

The detection job builds a new, minimal WorkflowData in buildDetectionEngineExecutionStep (threat_detection_inline_engine.go) and omits RunnerConfig:

threatDetectionData := &WorkflowData{
    Tools:             map[string]any{"bash": []any{"*"}},
    SafeOutputs:       nil,
    EngineConfig:      detectionEngineConfig,
    AI:                engineSetting,
    Features:          data.Features,
    Permissions:       data.Permissions,
    CachedPermissions: data.CachedPermissions,
    IsDetectionRun:    true,
    NetworkPermissions: &NetworkPermissions{ Allowed: getThreatDetectionAdditionalAllowedDomains(data) },
    SandboxConfig:     &SandboxConfig{ Agent: &AgentSandboxConfig{ Type: SandboxTypeAWF } },
    // RunnerConfig is NOT copied from `data`  ← the bug
}
...
installSteps   := engine.GetInstallationSteps(threatDetectionData)      // → no staging step
executionSteps := engine.GetExecutionSteps(threatDetectionData, logFile) // → spawns /usr/local/bin/copilot

So for the detection job only, isArcDindTopology(threatDetectionData) == false: no "Copy Copilot CLI to daemon-visible path" step is emitted, and the engine is spawned from /usr/local/bin/copilot. But the detection engine still runs inside the AWF chroot (SandboxConfig.Agent.Type = AWF), whose filesystem is the dind daemon's — where /usr/local/bin/copilot does not exist. → spawn ENOENT.

(Some arc-dind handling is applied to detection — e.g. the tcp://-gated chroot binaries/identity patch is present in the compiled detection job — which is what makes the missing RunnerConfig in the Copilot-path resolution the specific gap.)

Evidence (same frontmatter, compiled lock)

agent job detection job
arc-dind topology in awf-config present absent
"Copy Copilot CLI to daemon-visible path" step present absent
Copilot binary passed to copilot_harness.cjs ${RUNNER_TEMP}/gh-aw/bin/copilot /usr/local/bin/copilot

Reproduction

  1. Compile any engine: copilot workflow with runner: topology: arc-dind and safe-outputs.threat-detection enabled (inline engine; gh-aw-detection feature off).
  2. Run it on an ARC runner with a dind sidecar, where the AWF chroot executes on the dind daemon's filesystem (Copilot installed to /usr/local/bin on the runner is not present there).
  3. Agent job succeeds; detection job is green, but its log shows spawn /usr/local/bin/copilot ENOENT and No THREAT_DETECTION_RESULT found.

Proposed fix + implementation plan

Propagate the runner topology into the detection WorkflowData so the detection engine's install and execution use the same arc-dind daemon-visibility handling as the agent job.

  1. pkg/workflow/threat_detection_inline_engine.go — in buildDetectionEngineExecutionStep, carry the topology into the constructed threatDetectionData:

    threatDetectionData := &WorkflowData{
        ...
        RunnerConfig: data.RunnerConfig, // propagate runner.topology (e.g. arc-dind) to the detection job
    }

    This flips isArcDindTopology(threatDetectionData) to true on arc-dind, so engine.GetInstallationSteps emits the "Copy Copilot CLI to daemon-visible path" step and resolveCopilotCommand returns the daemon-visible ${RUNNER_TEMP}/gh-aw/bin/copilot.

  2. Resolve the rootless/sudo interaction. The detection execution currently runs sudo -E awf … --enable-host-access, whereas arc-dind agent execution is rootless (sudo: false; enforced by validateArcDindRootless in runner_topology_validation.go). Decide whether the detection job under arc-dind should also run rootless (consistent with the agent) and adjust the detection AWF command so validateArcDindRootless passes for the detection steps. This design decision is surfaced by step 1 and should be handled in the same change.

  3. Chroot mounts. Confirm the detection chroot receives the same daemon-visible mounts as the agent (workspace, ${RUNNER_TEMP}/gh-aw, tool-cache) so the staged Copilot and its Node runtime are reachable.

  4. Tests (pkg/workflow/threat_detection_test.go; mirror the agent assertions in compiler_yaml_main_job_test.go):

    • Given runner: topology: arc-dind + engine: copilot + threat-detection enabled, assert the compiled detection job (a) contains a "Copy Copilot CLI to daemon-visible path" step and (b) passes ${RUNNER_TEMP}/gh-aw/bin/copilot to copilot_harness.cjs (not /usr/local/bin/copilot).
    • Regression: without arc-dind, the detection job still uses /usr/local/bin/copilot.
  5. Make the silent failure visible (defense-in-depth): when the detection engine step exits non-zero with no output under continue-on-error, emit a warning/annotation from the parse-and-conclude step (the No THREAT_DETECTION_RESULT found branch) so a broken detection engine is surfaced rather than reported as a green success.

  6. Docs: note arc-dind support for the threat-detection job in docs/src/content/docs/reference/threat-detection.md.

  7. Run make agent-finish (build, test, recompile, format, lint) before completing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions